Post

[LLMOps] Day 4: 검색 최적화와 하이브리드 서치 - 정확도를 높이는 고급 기법

[LLMOps] Day 4: 검색 최적화와 하이브리드 서치 - 정확도를 높이는 고급 기법

서론: 벡터 검색의 한계

순수 벡터 검색(Semantic Search)은 강력하지만, 놓치는 것이 있다.

  • 키워드 매칭 실패: “GPT-4”를 검색할 때, “GPT-4”가 정확히 포함된 문서를 찾아야 하는데 의미적으로 유사한 “LLM” 문서가 상위에 올라올 수 있다.
  • 희귀 용어 처리 부족: “PostgreSQL 15의 pg_stat_statements” 같은 구체적 기술 용어는 임베딩 공간에서 제대로 표현되지 않는다.
  • 순서 민감도: “Python에서 Rust 호출”과 “Rust에서 Python 호출”은 의미가 다른데, 벡터는 순서를 제대로 구분 못할 수 있다.

프로덕션 RAG 시스템은 하이브리드 검색(Hybrid Search)을 사용한다. 벡터 검색과 키워드 검색을 결합하여 두 가지 장점을 모두 가져간다.

1. 하이브리드 검색: BM25 + 벡터 검색

1.1 BM25 (Best Matching 25)

정보 검색의 고전적 알고리즘이지만, 여전히 강력하다.

핵심 아이디어:

  • TF (Term Frequency): 문서에서 단어가 많이 등장할수록 관련성이 높다.
  • IDF (Inverse Document Frequency): 드물게 등장하는 단어일수록 중요하다 (예: “the”보다 “PyO3”가 중요).
  • 문서 길이 정규화: 긴 문서가 유리하지 않도록 보정.

수식:

1
Score(D, Q) = Σ IDF(qi) · (f(qi, D) · (k1 + 1)) / (f(qi, D) + k1 · (1 - b + b · |D| / avgdl))

실전 구현:

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
from rank_bm25 import BM25Okapi
import numpy as np

# 문서 토큰화
documents = [
    "Rust는 메모리 안전성을 보장하는 시스템 프로그래밍 언어이다",
    "PyO3를 사용하면 Rust로 Python 확장을 작성할 수 있다",
    "Python은 생산성이 높지만 성능은 C보다 느리다"
]

tokenized_docs = [doc.split() for doc in documents]

# BM25 인덱스 생성
bm25 = BM25Okapi(tokenized_docs)

# 검색
query = "Rust Python 확장"
tokenized_query = query.split()

# 각 문서의 BM25 점수
scores = bm25.get_scores(tokenized_query)
print(scores)
# [0.43, 1.28, 0.21]  ← 두 번째 문서가 가장 관련성 높음

# Top-K 검색
top_n = np.argsort(scores)[::-1][:2]
print([documents[i] for i in top_n])

1.2 하이브리드 검색 구현

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
from typing import List, Tuple
import numpy as np

class HybridSearcher:
    def __init__(self, documents: List[str], embeddings: List[List[float]]):
        # BM25 인덱스
        tokenized = [doc.split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized)

        # 벡터 인덱스
        self.embeddings = np.array(embeddings)
        self.documents = documents

    def vector_search(self, query_embedding: List[float], top_k: int = 10) -> List[Tuple[int, float]]:
        """코사인 유사도 기반 벡터 검색"""
        query_vec = np.array(query_embedding)

        # 코사인 유사도 계산
        similarities = np.dot(self.embeddings, query_vec) / (
            np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_vec)
        )

        # Top-K 인덱스
        top_indices = np.argsort(similarities)[::-1][:top_k]

        return [(idx, similarities[idx]) for idx in top_indices]

    def keyword_search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
        """BM25 기반 키워드 검색"""
        tokenized_query = query.split()
        scores = self.bm25.get_scores(tokenized_query)

        top_indices = np.argsort(scores)[::-1][:top_k]

        return [(idx, scores[idx]) for idx in top_indices]

    def hybrid_search(
        self,
        query: str,
        query_embedding: List[float],
        alpha: float = 0.5,
        top_k: int = 5
    ) -> List[Tuple[int, float]]:
        """
        하이브리드 검색

        alpha: 벡터 검색 가중치 (0~1)
               0 = 순수 키워드, 1 = 순수 벡터, 0.5 = 균형
        """
        # 두 검색 수행
        vector_results = self.vector_search(query_embedding, top_k=20)
        keyword_results = self.keyword_search(query, top_k=20)

        # 점수 정규화 (0~1 범위)
        def normalize_scores(results):
            scores = [score for _, score in results]
            min_score, max_score = min(scores), max(scores)
            if max_score == min_score:
                return [(idx, 0.5) for idx, _ in results]
            return [
                (idx, (score - min_score) / (max_score - min_score))
                for idx, score in results
            ]

        vector_results = normalize_scores(vector_results)
        keyword_results = normalize_scores(keyword_results)

        # 점수 병합
        combined_scores = {}

        for idx, score in vector_results:
            combined_scores[idx] = alpha * score

        for idx, score in keyword_results:
            if idx in combined_scores:
                combined_scores[idx] += (1 - alpha) * score
            else:
                combined_scores[idx] = (1 - alpha) * score

        # 정렬 후 Top-K
        sorted_results = sorted(
            combined_scores.items(),
            key=lambda x: x[1],
            reverse=True
        )[:top_k]

        return sorted_results

# 사용 예시
results = searcher.hybrid_search(
    query="Rust로 Python 확장 만들기",
    query_embedding=query_embedding,
    alpha=0.7  # 벡터 검색에 70% 가중치
)

for idx, score in results:
    print(f"{score:.2f}: {documents[idx][:50]}...")

1.3 Reciprocal Rank Fusion (RRF)

점수를 직접 합치는 대신, 순위를 기반으로 병합하는 방법이다.

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 reciprocal_rank_fusion(
    vector_results: List[Tuple[int, float]],
    keyword_results: List[Tuple[int, float]],
    k: int = 60
) -> List[Tuple[int, float]]:
    """
    RRF 공식: Score = Σ 1 / (k + rank)

    k: 상수 (보통 60), 상위 순위와 하위 순위 간 점수 차이 조절
    """
    rrf_scores = {}

    # 벡터 검색 결과
    for rank, (doc_id, _) in enumerate(vector_results):
        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank + 1)

    # 키워드 검색 결과
    for rank, (doc_id, _) in enumerate(keyword_results):
        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank + 1)

    # 정렬
    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)

# 사용 예시
rrf_results = reciprocal_rank_fusion(vector_results, keyword_results)

장점: 점수 스케일이 다른 두 검색 결과를 합칠 때, 정규화 없이 사용 가능.

2. 리랭킹 (Reranking)

검색은 빠르게, 정확도는 리랭커에서.

2.1 Cross-Encoder vs Bi-Encoder

특성Bi-Encoder (임베딩)Cross-Encoder (리랭커)
속도매우 빠름 (사전 계산 가능)느림 (실시간 계산 필요)
정확도중간매우 높음
사용 시점1차 검색 (Top-100)2차 정렬 (Top-10)
구조Query와 Doc 독립적으로 인코딩Query와 Doc을 함께 입력

핵심 전략: Bi-Encoder로 100개 후보를 빠르게 찾고, Cross-Encoder로 10개로 압축.

2.2 Cohere Rerank API

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
import cohere

co = cohere.Client("YOUR_API_KEY")

# 1차 검색 결과
documents = [
    "Rust는 메모리 안전성을 보장한다",
    "PyO3를 사용하면 Rust로 Python 확장을 만들 수 있다",
    "Python은 생산성이 높다"
]

query = "Rust로 Python 확장 만들기"

# 리랭킹
rerank_response = co.rerank(
    model="rerank-multilingual-v3.0",
    query=query,
    documents=documents,
    top_n=3
)

for result in rerank_response.results:
    print(f"Score: {result.relevance_score:.2f}, Doc: {documents[result.index]}")

# 출력:
# Score: 0.95, Doc: PyO3를 사용하면 Rust로 Python 확장을 만들 수 있다
# Score: 0.42, Doc: Rust는 메모리 안전성을 보장한다
# Score: 0.12, Doc: Python은 생산성이 높다

비용: $2.00 per 1,000 searches (문서 10개 기준)

2.3 오픈소스 리랭커

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sentence_transformers import CrossEncoder

# 모델 로드 (한 번만)
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

# 리랭킹
query = "Rust로 Python 확장 만들기"
documents = [...]  # 검색 결과

# Query-Document 쌍 생성
pairs = [[query, doc] for doc in documents]

# 점수 계산
scores = reranker.predict(pairs)

# 정렬
reranked = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)

for doc, score in reranked[:3]:
    print(f"{score:.2f}: {doc[:50]}...")

성능:

  • 레이턴시: ~50ms per document (CPU), ~5ms (GPU)
  • 정확도: Cohere 대비 약 85% 수준
  • 비용: 무료 (호스팅 비용만)

3. 쿼리 확장 (Query Expansion)

사용자 질문을 다양한 형태로 변형하여 검색 범위를 넓힌다.

3.1 HyDE (Hypothetical Document Embeddings)

아이디어: 질문을 직접 임베딩하지 말고, “이런 답변이 있을 것이다”라는 가상의 문서를 생성한 후 임베딩한다.

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
from openai import OpenAI

client = OpenAI()

def hyde_search(query: str, vector_db):
    # 1. LLM으로 가상 답변 생성
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[{
            "role": "user",
            "content": f"다음 질문에 대한 답변을 200자 이내로 작성하세요:\n{query}"
        }],
        max_tokens=200
    )

    hypothetical_answer = response.choices[0].message.content

    # 2. 가상 답변을 임베딩
    embedding = get_embedding(hypothetical_answer)

    # 3. 벡터 검색
    results = vector_db.search(embedding, top_k=5)

    return results

# 사용 예시
results = hyde_search("Rust로 Python 확장 만드는 방법")

효과: 기술 문서 검색에서 10~15% 정확도 향상 (특히 “how-to” 질문)

3.2 Multi-Query

쿼리를 여러 개로 변형하여 검색 후 결과를 병합한다.

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
def multi_query_search(query: str, vector_db):
    # 1. 쿼리 변형 생성
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[{
            "role": "user",
            "content": f"""다음 질문을 다른 표현으로 3가지 변형하세요:

질문: {query}

변형1:
변형2:
변형3:"""
        }]
    )

    # 변형된 쿼리 추출 (파싱 로직 생략)
    queries = [query] + parse_variations(response.choices[0].message.content)

    # 2. 각 쿼리로 검색
    all_results = []
    for q in queries:
        embedding = get_embedding(q)
        results = vector_db.search(embedding, top_k=10)
        all_results.append(results)

    # 3. RRF로 병합
    return reciprocal_rank_fusion(*all_results)

4. 실전 파이프라인: 모든 기법 결합

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
class AdvancedRAG:
    def __init__(self, vector_db, bm25_index, reranker):
        self.vector_db = vector_db
        self.bm25 = bm25_index
        self.reranker = reranker

    def search(self, query: str, top_k: int = 5):
        # 1. 쿼리 확장 (Multi-Query)
        expanded_queries = self.expand_query(query)

        # 2. 하이브리드 검색
        all_candidates = []
        for q in expanded_queries:
            # 벡터 검색
            vector_results = self.vector_db.search(q, top_k=20)
            # 키워드 검색
            keyword_results = self.bm25.search(q, top_k=20)
            # RRF 병합
            candidates = reciprocal_rank_fusion(vector_results, keyword_results)
            all_candidates.extend(candidates)

        # 중복 제거
        unique_docs = list(set([doc_id for doc_id, _ in all_candidates]))

        # 3. 리랭킹
        reranked = self.reranker.rerank(query, unique_docs, top_k=top_k)

        return reranked

성능:

  • Top-5 정확도: 순수 벡터 검색 대비 25% 향상
  • 레이턴시: ~400ms (캐싱 없을 때)
  • 비용: 쿼리당 $0.003 (임베딩 + 리랭킹)

5. A/B 테스트 필수

각 기법의 효과는 데이터셋마다 다르다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 평가 세트 준비
test_queries = [
    ("Rust로 Python 확장 만들기", ["doc_123", "doc_456"]),  # (쿼리, 정답 문서 ID)
    # ...
]

# 정확도 측정
def evaluate(search_fn, test_set):
    hits = 0
    for query, ground_truth in test_set:
        results = search_fn(query, top_k=5)
        result_ids = [r[0] for r in results]

        # Top-5에 정답이 있는지 확인
        if any(gt in result_ids for gt in ground_truth):
            hits += 1

    return hits / len(test_set)

# 비교
print(f"순수 벡터: {evaluate(vector_search, test_queries):.2%}")
print(f"하이브리드: {evaluate(hybrid_search, test_queries):.2%}")
print(f"하이브리드 + 리랭킹: {evaluate(advanced_search, test_queries):.2%}")

다음 단계

검색 품질을 높였으니, 이제 프로덕션 배포를 준비할 차례다. Day 5에서는 모니터링, 비용 최적화, 장애 대응을 다룰 것이다.


This post is licensed under CC BY 4.0 by the author.