7장. 자체 임베딩과 벡터DB, 검색 품질
실습 4 — 검색 품질 재기
it_helpdesk_qa.jsonl 의 질문 36개로 키워드 검색과 의미 검색의 hit@1, hit@3, MRR 을 잽니다. 질문마다 "뜻"(문서와 낱말이 다른 질문)과 "낱말"(코드나 이름이 그대로 든 질문) 유형이 적혀 있어, 유형별로도 봅니다.
1단계. 실습 2와 같은 준비
import json
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer
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)
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(chunks)
print("질문 수:", len(qa), "· 유형:", pd.Series([r["유형"] for r in qa]).value_counts().to_dict())
print(qa[2])질문 수: 36 · 유형: {'뜻': 28, '낱말': 8}
{'질문': '호텔에서 원격 접속이 자꾸 안 돼요.', '근거': '호텔 와이파이가 VPN 통신을 막을', '유형': '뜻'}2단계. 질문마다 정답의 순위 구하기
# 질문을 한꺼번에 임베딩한다(하나씩 할 때보다 빠르다)
Q = emb.encode(["query: " + r["질문"] for r in qa], normalize_embeddings=True)
sem_scores = Q @ E.T # (질문 수, 조각 수)
kw_scores = cosine_similarity(vec.transform([r["질문"] for r in qa]), X)
def rank_of_gold(scores, row):
"""정답 조각(근거 글이 든 조각)의 순위. 1 부터 센다"""
order = np.argsort(-scores).tolist()
gold = [i for i, c in enumerate(chunks) if row["근거"] in c]
return min(order.index(g) for g in gold) + 1
rows = []
for n, r in enumerate(qa):
rows.append({"질문": r["질문"], "유형": r["유형"],
"키워드": rank_of_gold(kw_scores[n], r), "의미": rank_of_gold(sem_scores[n], r)})
ranks = pd.DataFrame(rows)
print(ranks.head(6).to_string(index=False)) 질문 유형 키워드 의미
집에서 회사 시스템에 접속하려면 무엇을 써야 하나요? 뜻 1 1
VPN-809 오류가 떠요. 낱말 1 1
호텔에서 원격 접속이 자꾸 안 돼요. 뜻 3 2
암호는 얼마나 자주 새로 정해야 하나요? 뜻 8 1
예전에 쓰던 암호를 다시 써도 되나요? 뜻 1 8
로그인을 여러 번 실패해서 들어갈 수가 없어요. 뜻 10 13단계. hit@1, hit@3, MRR
def metrics(r):
"""순위 열 하나로 세 지표를 계산한다"""
return pd.Series({"hit@1": (r == 1).mean(), "hit@3": (r <= 3).mean(), "MRR": (1 / r).mean()}).round(3)
table = pd.DataFrame({"키워드": metrics(ranks["키워드"]), "의미": metrics(ranks["의미"])})
print(table)
# 유형별 hit@3
print(ranks.groupby("유형")[["키워드", "의미"]].agg(lambda r: (r <= 3).mean()).round(3)) 키워드 의미
hit@1 0.722 0.833
hit@3 0.917 0.944
MRR 0.812 0.898
키워드 의미
유형
낱말 1.000 1.000
뜻 0.893 0.9294단계. 둘이 갈린 질문 읽기
# 한쪽은 1등인데 다른 쪽은 2등 아래인 질문
diff = ranks[(ranks["키워드"] == 1) != (ranks["의미"] == 1)]
print(diff.to_string(index=False)) 질문 유형 키워드 의미
암호는 얼마나 자주 새로 정해야 하나요? 뜻 8 1
예전에 쓰던 암호를 다시 써도 되나요? 뜻 1 8
로그인을 여러 번 실패해서 들어갈 수가 없어요. 뜻 10 1
거래처 손님이 인터넷을 쓰려면 어떻게 하나요? 뜻 3 1
그만두는 직원의 메일은 어떻게 되나요? 뜻 3 1
첫 출근 날 노트북은 어디서 받나요? 뜻 2 1
이상한 메일의 첨부 파일을 열어 버렸어요. 뜻 2 1
계정 문제는 얼마 만에 처리해 주나요? 낱말 1 2
복합기 잉크가 떨어졌어요. 뜻 1 2
솔내가드는 무엇인가요? 낱말 2 1이 실습이 뜻하는 것
이 실습은 두 검색 직원에게 같은 질문지 36장을 주고 성적을 매기는 것과 같습니다. 질문지와 두 직원을 준비하고(1단계), 질문마다 정답 조각을 몇 번째로 내놓았는지 적고(2단계), 그 순위로 세 가지 점수를 내고(3단계), 두 직원의 판단이 갈린 질문만 따로 읽습니다(4단계). 여기서 두 직원이 키워드 검색과 의미 검색, 세 가지 점수가 hit@1(1등에 정답이 온 비율), hit@3(위 3개 안에 정답이 든 비율), MRR(Mean Reciprocal Rank, 평균 역순위: 정답이 1등이면 1, 2등이면 1/2, 3등이면 1/3 을 주어 평균한 값)입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 질문 | 질문 수: 36 · 유형: {'뜻': 28, '낱말': 8} | 문서와 낱말이 다른 "뜻" 질문 28개, 코드나 이름이 그대로 든 "낱말" 질문 8개입니다 |
| 1. 질문 하나 | {'질문': '호텔에서 원격 접속이 자꾸 안 돼요.', '근거': …} | 정답은 조각 번호가 아니라 "근거 글이 든 조각"으로 정합니다 |
| 2. 순위 표 | 암호 주기 8 1, 예전 암호 1 8, 로그인 실패 10 1 | 질문마다 정답 조각이 키워드 검색과 의미 검색에서 몇 등이었는지입니다. 숫자가 작을수록 좋습니다 |
| 3. hit@1 | 0.722 대 0.833 | 36문제 중 1등으로 맞힌 것이 키워드 26개, 의미 30개입니다 |
| 3. hit@3 | 0.917 대 0.944 | 위 3개 안에 든 것은 33개 대 34개로 차이가 작습니다 |
| 3. MRR | 0.812 대 0.898 | 정답을 얼마나 위에 올렸는지의 평균입니다. 1에 가까울수록 정답이 앞에 옵니다 |
| 3. 유형별 hit@3 | 낱말 1.000 1.000, 뜻 0.893 0.929 | 코드가 그대로 든 질문은 둘 다 모두 찾았고, 차이는 낱말이 다른 "뜻" 질문(28개 중 25개 대 26개)에서 납니다 |
| 4. 갈린 질문 | 10개 | 한쪽만 1등을 맞힌 질문입니다. 의미 검색이 이긴 것이 7개, 키워드 검색이 이긴 것이 3개입니다 |
한 줄로 줄이면, 질문 36개로 재 보니 의미 검색이 세 점수 모두 앞섰지만 키워드 검색이 이기는 질문도 3개 있다는 실습입니다. 두 검색이 틀리는 질문이 서로 다르다는 것이 실습 6에서 둘을 섞는(하이브리드) 까닭이 됩니다. 36문제라 한 문제가 hit@1 을 0.028쯤 움직이므로, hit@3 의 33개 대 34개 같은 작은 차이는 흐름으로만 읽습니다.
코드 설명
rank_of_gold()는 정답 조각의 순위를 구합니다. 정답을 조각 번호가 아니라 "근거 글이 든 조각"으로 정해 두어서, 실습 5처럼 조각을 다르게 나눠도 같은 함수로 잽니다.- 의미 검색이 세 지표 모두 앞섰습니다. hit@1 은 0.722 대 0.833(36개 가운데 26개 대 30개), hit@3 은 0.917 대 0.944, MRR 은 0.812 대 0.898 입니다.
- 유형별 hit@3 에서 "낱말" 질문은 둘 다 1.000 입니다. 이 문서에서는 의미 검색도
VPN-809,HW-05같은 코드를 잘 찾았습니다. 차이는 "뜻" 질문에서 나서(0.893 대 0.929), 의미 검색의 강점이 "낱말이 다른 질문"에 있다는 것이 보입니다. - 둘이 갈린 질문은 10개입니다. 의미 검색이 이긴 것이 7개(암호를 자주 정하기, 로그인 실패 등)이고, 키워드 검색이 이긴 것도 3개(예전 암호, 계정 문제 처리 기한, 복합기 잉크)입니다. "복합기"처럼 질문과 문서에 같은 낱말이 있으면 키워드 검색이 정확합니다. 두 검색이 틀리는 질문이 서로 달라서 섞을 까닭이 생깁니다.