목차
- 사용 라이브러리 및 기본 설정
- 리랭킹 개념 및 파이프라인
- Bi-encoder vs Cross-encoder
- 키워드 리랭킹 (Keyword Rerank)
- BM25 리랭킹 (BM25Reranker)
- LLM 리랭킹 — Pointwise (llm_rerank)
- Hybrid 리랭킹
- Listwise 리랭킹 (llm_listwise_rerank)
- ScoreFilter (Threshold 필터링)
- 자주 나오는 실수 / 주의사항
- [보충] 리랭킹 기법 비교
1. 사용 라이브러리 및 기본 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import os, re, time, json, math
import numpy as np
import pandas as pd
from collections import Counter
from scipy import stats
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain_classic.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_classic.retrievers.document_compressors import LLMChainExtractor
from dotenv import load_dotenv
load_dotenv()
MODEL = "gpt-4o-mini"
llm = ChatOpenAI(model=MODEL)
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
|
샘플 문서
1
2
3
4
5
6
7
8
9
10
11
12
13
| documents = [
Document(page_content="트랜스포머는 Self-Attention 메커니즘을 사용하여 시퀀스 데이터를 병렬로 처리하는 딥러닝 아키텍처입니다.", metadata={"id": "d1"}),
Document(page_content="BERT는 양방향 트랜스포머 인코더로 MLM과 NSP 태스크로 사전학습됩니다.", metadata={"id": "d2"}),
Document(page_content="GPT는 단방향 트랜스포머 디코더로 다음 토큰 예측 방식으로 학습합니다.", metadata={"id": "d3"}),
Document(page_content="RAG는 검색 증강 생성 기법으로 외부 지식을 LLM에 결합하여 할루시네이션을 줄입니다.", metadata={"id": "d4"}),
Document(page_content="벡터 데이터베이스는 임베딩 벡터를 저장하고 유사도 기반 검색을 수행합니다. FAISS, Pinecone 등이 있습니다.", metadata={"id": "d5"}),
Document(page_content="파인튜닝은 사전학습된 모델을 특정 도메인 데이터로 추가 학습하는 기법입니다. LoRA, QLoRA가 효율적입니다.", metadata={"id": "d6"}),
Document(page_content="프롬프트 엔지니어링은 LLM에 효과적인 지시를 설계하는 기법입니다. Few-shot, CoT 등이 있습니다.", metadata={"id": "d7"}),
Document(page_content="토큰화는 텍스트를 모델이 처리할 수 있는 단위로 분할하는 과정입니다. BPE, WordPiece 등이 사용됩니다.", metadata={"id": "d8"}),
]
vectorstore = FAISS.from_documents(documents, embeddings_model)
bm25_retriever = BM25Retriever.from_documents(documents, k=5)
|
2. 리랭킹 개념 및 파이프라인
리랭킹은 1차 검색 결과를 더 정확한 기준으로 재정렬하는 과정입니다.
1
2
3
4
5
6
7
8
9
10
11
| [전체 RAG 파이프라인]
사용자 쿼리
↓
[1차 검색] Vector Search / BM25 / Hybrid
↓ 후보 문서 (doc1 ~ doc5)
[리랭킹] Cross-encoder / BM25 / LLM
↓ 재정렬된 문서
[생성] Generator (LLM)
↓
최종 답변
|
3. Bi-encoder vs Cross-encoder
1
2
3
4
5
6
7
8
9
10
11
12
| [Bi-encoder — 일반 벡터 검색]
query ──(emb_model)──► query_emb
doc1 ──(emb_model)──► doc1_emb
──► 코사인 유사도로 비교 (문장 각각 따로 임베딩)
[Cross-encoder — 쌍(pair)으로 입력]
(query + doc1) ──(emb_model)──► 관련성 점수
(query + doc2) ──(emb_model)──► 관련성 점수
──► 쿼리-문서 쌍의 상호작용까지 반영
[LLM 기반]
(query, doc1) ──(LLM)──► score 또는 True/False
|
| 방식 | 속도 | 정확도 | 특징 |
|---|
| Bi-encoder | 빠름 | 보통 | 사전 임베딩 가능, 대규모 검색에 적합 |
| Cross-encoder | 느림 | 높음 | 쌍으로 입력 → 상호작용 반영 |
| LLM reranker | 가장 느림 | 가장 높음 | 비용 높지만 가장 유연 |
4. 키워드 리랭킹 (Keyword Rerank)
작동 방식
쿼리 단어가 문서에 몇 개나 포함됐는지 세어 기존 점수에 가산점을 더합니다.
1
2
3
4
5
6
7
8
9
10
11
12
| 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 # 키워드 히트당 +0.1
reranked.append((doc, new_score, orig_score))
reranked.sort(key=lambda x: x[1], reverse=True)
return reranked # (doc, 새 점수, 원래 점수)
|
순위 변화 추적
1
2
3
4
5
6
7
8
9
10
11
12
13
| def rank_change(original_results, reranked_results):
orig_ranks = {doc_.metadata['id']: i+1 for i, (doc_, _) in enumerate(original_results)}
new_ranks = {doc_.metadata['id']: i+1 for i, (doc_, _, _) in enumerate(reranked_results)}
changes = []
for doc_id in orig_ranks:
changes.append({
'doc_id': doc_id,
'before': orig_ranks[doc_id],
'after': new_ranks.get(doc_id, -1),
'change': orig_ranks[doc_id] - new_ranks.get(doc_id, -1)
})
return pd.DataFrame(changes).sort_values('after')
|
5. BM25 리랭킹 (BM25Reranker)
BM25 공식
1
2
3
4
5
6
7
8
| BM25(q, d) = Σ IDF(qt) × [ tf × (k1 + 1) ] / [ tf + k1 × (1 - b + b × |d|/avgDL) ]
- tf : 쿼리 단어의 문서 내 출현 빈도
- IDF : log((N - df + 0.5) / (df + 0.5) + 1)
- |d| : 문서 길이
- avgDL : 평균 문서 길이
- k1=1.5 : 단어 빈도 포화 제어 (높을수록 빈도 효과 강화)
- b=0.75 : 문서 길이 정규화 강도 (0이면 정규화 없음)
|
TF-IDF vs BM25: TF-IDF는 tf를 그대로 사용하지만 BM25는 tf 포화(saturation) 처리로 단어가 많이 반복돼도 점수가 무한정 올라가지 않음.
구현 코드
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
| 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])
df_count = Counter()
for tokens in tokenized:
for t in set(tokens):
df_count[t] += 1
N = len(docs)
query_tokens = query.lower().split()
scored = []
for i, doc in enumerate(docs):
score = 0.0
doc_len = len(tokenized[i])
tf_count = Counter(tokenized[i])
for qt in query_tokens:
tf = tf_count.get(qt, 0)
if tf == 0:
continue
df = df_count.get(qt, 0)
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / avg_dl)
score += idf * numerator / denominator
scored.append((doc, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored
|
6. LLM 리랭킹 — Pointwise (llm_rerank)
개념
각 문서를 1개씩 LLM에 넣어 관련성 점수(0.0~1.0)를 받아옵니다.
1
2
3
4
5
| [Pointwise 방식]
(query + doc1) ──(LLM)──► 0.9
(query + doc2) ──(LLM)──► 0.4
(query + doc3) ──(LLM)──► 0.7
→ 점수 내림차순으로 재정렬
|
구현 코드
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
| 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
)
score_template = ", ".join(
f'"{doc.metadata["id"]}": 0.0-1.0' for doc, _ in search_results
)
scoring_chain = ChatPromptTemplate.from_messages([
('system', '당신은 scoring 시스템입니다. 항상 json 형태로 출력하세요'),
('human', """다음 쿼리에 대해 각 문서의 관련성을 0.0~1.0으로 평가하세요.
쿼리 : {query}
문서들 : {docs_text}
JSON으로 답하세요:
{{"scores" : {{{score_template}}}}}""")
]) | llm | StrOutputParser()
result = scoring_chain.invoke({
'query': query, 'docs_text': docs_text, 'score_template': score_template
})
cleaned = result.strip()
if cleaned.startswith('```json'):
cleaned = cleaned.replace('```json', '').replace('```', '')
scores = json.loads(cleaned).get('scores', {})
scored = []
for doc, orig in search_results:
rerank_score = scores.get(doc.metadata['id'], 0.0)
scored.append((doc, float(rerank_score), orig))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
|
1
2
3
4
| 실행 예시 (query = "트랜스포머와 BERT의 관계"):
d2 (BERT) → 1.0 ← LLM이 가장 관련 높다고 판단
d1 (트랜스포머) → 0.4
d3 (GPT) → 0.3
|
7. Hybrid 리랭킹
BM25와 LLM 점수를 정규화 후 가중합으로 결합합니다.
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 hybrid_rerank(query, search_results, bm25_weight=0.3):
bm25 = BM25Reranker()
bm25_scored = bm25.rerank(query, search_results)
bm25_map = {doc.metadata['id']: score for doc, score in bm25_scored}
llm_scored = llm_rerank(query, search_results, top_k=len(search_results))
llm_map = {doc.metadata['id']: score for doc, score, _ in llm_scored}
def normalize(scores_dict):
vals = list(scores_dict.values())
min_, max_ = min(vals), max(vals)
range_ = max_ - min_ if max_ > min_ else 1e-8
return {k: (v - min_) / range_ for k, v in scores_dict.items()}
bm25_norm = normalize(bm25_map)
llm_norm = normalize(llm_map)
combined = []
for doc, _ in search_results:
did = doc.metadata['id']
score = bm25_weight * bm25_norm.get(did, 0) + (1 - bm25_weight) * llm_norm.get(did, 0)
combined.append((doc, score))
combined.sort(key=lambda x: x[1], reverse=True)
return combined
|
1
2
| bm25_weight = 0.3 → LLM 점수 70% + BM25 점수 30%
각 점수를 min-max 정규화한 뒤 가중합 → 스케일 차이 문제 해소
|
8. Listwise 리랭킹 (llm_listwise_rerank)
Pointwise vs Listwise 비교
1
2
3
4
5
6
7
| [Pointwise] : 문서 1개씩 LLM에 입력 → 점수
- 장점: 정확, 병렬 처리 가능, 이유 설명 명확
- 단점: 비용, 문서 간 상대적 비교 불가
[Listwise] : N개 문서 한번에 입력 → 순서 반환
- 장점: 문서 간 비교 가능
- 단점: 비용, 할루시네이션 / 컨텍스트 길이 제한
|
팁: 문서가 5개 이하면 listwise, 빠른 응답이 필요하면 pointwise 사용
구현 코드
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
| def llm_listwise_rerank(query, search_results):
docs_text = '\n'.join(
f"{i+1}. [{doc.metadata['id']}] {doc.page_content[:80]}"
for i, (doc, _) in enumerate(search_results)
)
ranking_chain = ChatPromptTemplate.from_messages([
('system', "당신은 문서 랭킹 시스템입니다"),
('human', """다음 문서들을 쿼리와의 관련성 순서로 정렬하세요.
쿼리 : {query}
문서들 : {docs_text}
가장 관련성 높은 순서대로 문서 번호를 쉼표로 나열하세요 (예: 3,1,5,2,4):""")
]) | llm | StrOutputParser()
result = ranking_chain.invoke({'query': query, 'docs_text': docs_text})
order = [int(x.strip()) for x in result.strip().split(',')]
docs_list = [doc for doc, _ in search_results]
reranked = []
for rank, idx in enumerate(order):
if 1 <= idx <= len(docs_list):
reranked.append((docs_list[idx-1], 1.0 - rank * 0.1))
return reranked
|
9. ScoreFilter (Threshold 필터링)
리랭킹 후 낮은 점수 문서를 제너레이터에 넘기기 전에 걸러냅니다.
1
2
3
4
| RAG 파이프라인에서 필터링 위치:
Retriever → Reranker → [ScoreFilter] → Generator
↑
점수 낮은 문서 차단 (노이즈 감소)
|
필터링 전략 3가지
1
2
3
4
5
6
7
8
9
10
11
12
13
| class ScoreFilter:
@staticmethod
def fixed_threshold(scored_docs, threshold=0.5):
"""고정 임계값: 0.5 이하 제거"""
return [(doc, s) for doc, s in scored_docs if s >= threshold]
@staticmethod
def dynamic_threshold(scored_docs, std_factor=1):
"""동적 임계값: 평균 - N×표준편차 이하 제거"""
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]
|
1
2
3
4
5
6
7
| fixed_threshold(threshold=0.5):
입력: [0.9, 0.85, 0.7, 0.5, 0.2]
출력: [0.9, 0.85, 0.7, 0.5] ← 0.2 제거
dynamic_threshold(std_factor=1):
mean=0.63, std=0.25 → threshold = 0.63 - 0.25 = 0.38
출력: [0.9, 0.85, 0.7, 0.5] ← 0.2 제거
|
score_gap 전략 (점수 급락 지점 탐지)은 다음 강의(04_08)에서 구현됩니다.
10. 자주 나오는 실수 / 주의사항
- Pointwise LLM rerank JSON 파싱 오류: LLM이
json 코드블록으로 감싸서 반환하는 경우 있음 → `cleaned.replace('json’, ‘’)` 처리 필수 - BM25 리랭킹 점수 0: 쿼리 토큰이 문서 내에 한 개도 없으면 점수가 0 → 키워드 중심 쿼리에서 유리, 의미 기반 쿼리에서는 불리
- Hybrid 정규화 분모가 0:
range_ = max_ - min_ 이 0이 될 수 있음 → if max_ > min_ else 1e-8 처리 필요 - Listwise 순서 파싱: LLM이 “3,1,5,2,4” 대신 다른 형식으로 반환할 수 있음 → 파싱 오류 예외 처리 추가 권장
- rank_change 함수 인자 형태:
original_results는 (doc, score) 튜플, reranked_results는 (doc, new_score, orig_score) 튜플 — 형태가 다르므로 주의
[보충] 리랭킹 기법 비교
| 기법 | 방식 | 장점 | 단점 |
|---|
| 키워드 리랭크 | 키워드 히트 수로 가산점 | 매우 빠름, 구현 단순 | 의미 고려 불가 |
| BM25 리랭크 | TF-IDF 개선판 공식 | 키워드 기반 정밀도 향상 | 의미적 유사도 반영 안 됨 |
| LLM Pointwise | 1개씩 관련성 점수화 | 정확, 이유 설명 가능 | 비용, 문서 간 비교 불가 |
| LLM Listwise | N개 한번에 정렬 | 문서 간 비교 | 비용, 컨텍스트 제한 |
| Hybrid | BM25 + LLM 가중합 | 균형 잡힌 성능 | 가중치 튜닝 필요 |
리랭킹 파이프라인 전체 흐름
1
2
3
4
5
6
7
8
9
10
11
12
| 쿼리
↓
[Vector Search] (Bi-encoder, FAISS)
↓ 후보 5~20개
[Reranker 선택]
├─ 빠른 응답 필요 → BM25 or Keyword Rerank
├─ 정확도 중요 → LLM Pointwise
└─ 문서 비교 필요 → LLM Listwise
↓
[ScoreFilter] → 낮은 점수 문서 제거
↓
[Generator] → 최종 답변 생성
|
끝