포스트

2026-04-07 강의 정리

주요 언어 모델 공급자 및 활용 (Claude, Gemini, Groq, Ollama, Huggingface)

2026-04-07 강의 정리

목차

  1. 사용 라이브러리 및 기본 설정
  2. 리랭킹 개념 및 파이프라인
  3. Bi-encoder vs Cross-encoder
  4. 키워드 리랭킹 (Keyword Rerank)
  5. BM25 리랭킹 (BM25Reranker)
  6. LLM 리랭킹 — Pointwise (llm_rerank)
  7. Hybrid 리랭킹
  8. Listwise 리랭킹 (llm_listwise_rerank)
  9. ScoreFilter (Threshold 필터링)
  10. 자주 나오는 실수 / 주의사항
  11. [보충] 리랭킹 기법 비교

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 Pointwise1개씩 관련성 점수화정확, 이유 설명 가능비용, 문서 간 비교 불가
LLM ListwiseN개 한번에 정렬문서 간 비교비용, 컨텍스트 제한
HybridBM25 + 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] → 최종 답변 생성

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

인기 태그