목차
- ScoreFilter 완성 (score_gap, AdaptiveFilter)
- Context Compression 개념
- MMR (Maximum Marginal Relevance) 개념
- Extractive Compress
- LLM Compress
- LangChain ContextualCompressionRetriever
- 압축 품질 평가 (evaluate_compression)
- MMR 기반 압축 (mmr_compress)
- Average Precision (AP) / mAP
- Intra-List Similarity (ILS)
- 자주 나오는 실수 / 주의사항
- [보충] 압축 기법 비교
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]
|
문서를 문장 단위로 분리한 뒤, 쿼리 단어가 많이 포함된 문장만 선택합니다.
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 Compress | LLM이 재작성/요약 | 자연스러운 압축, 의미 보존 | 비용, 환각 위험 |
| MMR Compress | 관련성 + 다양성 균형 | 중복 없이 다양한 정보 | 임베딩 비용, 속도 느림 |
| LangChain Contextual | LLMChainExtractor 자동화 | 파이프라인 통합 간편 | 내부 프롬프트 고정 |
전체 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 (다양성)
|
끝