LLM 엔지니어링7장 · 자체 임베딩과 벡터DB, 검색 품질19 / 22← 이전목차다음 →MetaContents

7장. 자체 임베딩과 벡터DB, 검색 품질

실습 6 — 하이브리드와 재정렬

실습 4의 두 점수를 가중 합과 RRF 로 섞고, 크로스 인코더로 재정렬해 봅니다. 처음 실행하면 재정렬 모델(470MB 쯤)을 내려받습니다.

1단계. 실습 4와 같은 준비

Python
import json, time
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer, CrossEncoder
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

text = open("it_helpdesk.md", encoding="utf-8").read()
chunks = ["## " + s.strip() for s in text.split("\n## ")[1:]]
qa = [json.loads(line) for line in open("it_helpdesk_qa.jsonl", encoding="utf-8")]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
E = emb.encode(["passage: " + c for c in chunks], normalize_embeddings=True)
Q = emb.encode(["query: " + r["질문"] for r in qa], normalize_embeddings=True)
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(chunks)
SEM = Q @ E.T                                                        # 의미 점수 (36, 30)
KW = cosine_similarity(vec.transform([r["질문"] for r in qa]), X)    # 키워드 점수 (36, 30)
GOLD = [[i for i, c in enumerate(chunks) if r["근거"] in c] for r in qa]

def evaluate(orders):
    """질문마다의 조각 순서 목록을 받아 hit@1, hit@3, MRR"""
    ranks = np.array([min(o.index(g) for g in gold) + 1 for o, gold in zip(orders, GOLD)])
    return {"hit@1": int((ranks == 1).sum()), "hit@3": int((ranks <= 3).sum()), "MRR": round(float((1 / ranks).mean()), 3)}

def order_of(scores):
    return np.argsort(-scores).tolist()

print("키워드:", evaluate([order_of(s) for s in KW]))
print("의미  :", evaluate([order_of(s) for s in SEM]))
출력
키워드: {'hit@1': 26, 'hit@3': 33, 'MRR': 0.812}
의미  : {'hit@1': 30, 'hit@3': 34, 'MRR': 0.898}

2단계. 가중 합의 α 바꿔 보기

Python
def minmax(a):
    return (a - a.min()) / (a.max() - a.min())

rows = []
for alpha in [0.0, 0.3, 0.5, 0.7, 0.8, 1.0]:
    # α 는 의미 점수의 무게, 1 - α 는 키워드 점수의 무게
    orders = [order_of(alpha * minmax(s) + (1 - alpha) * minmax(k)) for s, k in zip(SEM, KW)]
    rows.append({"α": alpha, **evaluate(orders)})
print(pd.DataFrame(rows).to_string(index=False))
출력
  α  hit@1  hit@3   MRR
0.0     26     33 0.812
0.3     28     33 0.857
0.5     30     33 0.888
0.7     30     35 0.905
0.8     30     34 0.901
1.0     30     34 0.898

3단계. RRF 로 섞기

Python
def rrf(order_a, order_b, k=60):
    """두 순서에서 순위의 역수 1/(k + 순위)를 더해 다시 줄 세운다"""
    score = {}
    for order in (order_a, order_b):
        for rank, i in enumerate(order, start=1):
            score[i] = score.get(i, 0) + 1 / (k + rank)
    return sorted(score, key=lambda i: -score[i])

rrf_orders = [rrf(order_of(k), order_of(s)) for s, k in zip(SEM, KW)]
print("RRF:", evaluate(rrf_orders))
출력
RRF: {'hit@1': 25, 'hit@3': 34, 'MRR': 0.829}

4단계. 크로스 인코더로 재정렬하기

Python
ce = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", device="cpu")
HYB = [order_of(0.7 * minmax(s) + 0.3 * minmax(k)) for s, k in zip(SEM, KW)]   # 2단계에서 고른 α = 0.7

start = time.time()
reranked = []
for r, order in zip(qa, HYB):
    cand = order[:10]                                         # 1단계: 하이브리드 위 10개
    ce_scores = ce.predict([(r["질문"], chunks[i]) for i in cand])
    new = [cand[i] for i in np.argsort(-ce_scores)]          # 2단계: 크로스 인코더 점수로 다시 줄 세운다
    reranked.append(new + order[10:])
print(f"재정렬 36문항 {time.time() - start:.1f}초")
print("하이브리드(α=0.7):", evaluate(HYB))
print("+ 재정렬         :", evaluate(reranked))
출력
재정렬 36문항 8.0초
하이브리드(α=0.7): {'hit@1': 30, 'hit@3': 35, 'MRR': 0.905}
+ 재정렬         : {'hit@1': 31, 'hit@3': 33, 'MRR': 0.901}

이 실습이 뜻하는 것

이 실습은 두 심사위원의 점수를 합쳐 순위를 정하고, 마지막에 결선 심사위원이 상위 10명을 다시 꼼꼼히 보는 오디션과 같습니다. 두 심사위원의 단독 성적을 먼저 보고(1단계), 두 점수를 같은 눈금으로 맞춘 뒤 비율(α)을 바꿔 더하고(2단계), 점수 대신 등수만으로 합쳐 보고(3단계), 결선 심사위원이 상위 10개를 질문과 한 쌍씩 읽어 다시 줄 세웁니다(4단계). 여기서 두 심사위원이 키워드 검색과 의미 검색, 점수를 비율로 더하는 법이 가중 합, 등수만 합치는 법이 RRF(Reciprocal Rank Fusion, 역순위 융합), 결선 심사위원이 크로스 인코더(cross-encoder, 질문과 조각을 한꺼번에 읽고 점수를 매기는 재정렬 모델)입니다. 비유와 다른 점은, 결선 심사위원은 상위 10개 밖은 보지 않아서 앞 단계에서 빠진 정답은 되살리지 못한다는 것입니다.

단계출력뜻
1. 기준키워드 26·33·0.812, 의미 30·34·0.898실습 4와 같은 성적입니다. hit@1, hit@3 을 비율 대신 맞힌 수(36개 중)로 적었습니다
2. α 0.026·33·0.812의미 점수의 무게가 0, 곧 키워드 검색만 쓴 것과 같습니다
2. α 0.730·35·0.905의미 70%, 키워드 30% 로 섞었을 때 hit@3 과 MRR 이 가장 높습니다
2. α 1.030·34·0.898의미 검색만 쓴 것과 같습니다. α 0.7 이 이보다 위 3개를 하나 더 맞혔습니다
3. RRF25·34·0.829등수만 섞으니 1등 맞힌 수가 키워드 검색(26)보다도 낮아졌습니다
4. 시간재정렬 36문항 8.0초질문마다 10쌍을 읽느라 CPU 로 8초가 더 들었습니다
4. 하이브리드30·35·0.9052단계의 α 0.7 과 같은 값입니다
4. 재정렬31·33·0.9011등은 하나 늘었지만 위 3개는 둘 줄어, MRR 은 거의 같습니다

한 줄로 줄이면, 두 검색을 알맞은 비율(α 0.7)로 섞으면 조금 나아지지만, 등수로 섞는 RRF 와 재정렬은 이 문서에서 이득이 아니었다는 실습입니다. 차이가 한두 문제라 질문 36개로는 우연과 가리기 어렵고, α 0.7 도 이 질문 묶음에 맞춰 고른 값이라 새 질문에서 같은 성적이 나온다는 보장은 없습니다. 프로젝트 1은 이 하이브리드와 재정렬을 사내 규정집에 쓰고, Ollama 의 모델 넷을 견줍니다.

코드 설명

  • evaluate() 는 실습 4의 지표 계산을 "질문마다의 조각 순서"를 받도록 바꾼 것입니다. 검색 방법이 무엇이든 순서만 만들면 같은 함수로 잽니다.
  • 섞기 전의 기준은 실습 4와 같습니다. 키워드 hit@1 26, hit@3 33, MRR 0.812, 의미 hit@1 30, hit@3 34, MRR 0.898 입니다.
  • α = 0.7(의미 70%, 키워드 30%)에서 hit@3 이 35 로 가장 높고 MRR 도 0.905 로 가장 높습니다. hit@1 은 0.5 부터 30 으로 의미 검색만 쓸 때와 같습니다. 키워드 쪽 무게가 커질수록(α 가 작을수록) 떨어집니다.
  • RRF 는 hit@3 은 34 로 의미 검색과 같지만, hit@1 은 25 로 키워드 검색(26)보다도 낮아졌습니다. 순위만 섞으니 키워드 검색이 1등으로 올린 엉뚱한 조각도 의미 검색의 1등과 같은 무게를 받기 때문입니다. 본문 "하이브리드 검색"의 주의할 점 그대로, 섞는다고 늘 나아지지는 않습니다.
  • 재정렬은 hit@1 을 30 에서 31 로 하나 올렸지만, hit@3 은 35 에서 33 으로 내려 MRR(0.901)은 거의 같습니다. 이 문서와 질문에서는 재정렬의 득이 크지 않았고, CPU 에서 36문항에 8초가 더 들었습니다. 재정렬도 넣기 전후를 재서 쓸지 정합니다.
← 이전 절7장 · 실습 5 — 조각 크기 견주기다음 절 →7장 · 프로젝트 1 — 사내 규정 검색기와 모델 비교표