7장. 자체 임베딩과 벡터DB, 검색 품질
실습 6 — 하이브리드와 재정렬
실습 4의 두 점수를 가중 합과 RRF 로 섞고, 크로스 인코더로 재정렬해 봅니다. 처음 실행하면 재정렬 모델(470MB 쯤)을 내려받습니다.
1단계. 실습 4와 같은 준비
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단계. 가중 합의 α 바꿔 보기
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.8983단계. RRF 로 섞기
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단계. 크로스 인코더로 재정렬하기
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.0 | 26·33·0.812 | 의미 점수의 무게가 0, 곧 키워드 검색만 쓴 것과 같습니다 |
| 2. α 0.7 | 30·35·0.905 | 의미 70%, 키워드 30% 로 섞었을 때 hit@3 과 MRR 이 가장 높습니다 |
| 2. α 1.0 | 30·34·0.898 | 의미 검색만 쓴 것과 같습니다. α 0.7 이 이보다 위 3개를 하나 더 맞혔습니다 |
| 3. RRF | 25·34·0.829 | 등수만 섞으니 1등 맞힌 수가 키워드 검색(26)보다도 낮아졌습니다 |
| 4. 시간 | 재정렬 36문항 8.0초 | 질문마다 10쌍을 읽느라 CPU 로 8초가 더 들었습니다 |
| 4. 하이브리드 | 30·35·0.905 | 2단계의 α 0.7 과 같은 값입니다 |
| 4. 재정렬 | 31·33·0.901 | 1등은 하나 늘었지만 위 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초가 더 들었습니다. 재정렬도 넣기 전후를 재서 쓸지 정합니다.