7장. 자체 임베딩과 벡터DB, 검색 품질
하이브리드 검색
맛집을 고를 때 별점만 보지 않고 리뷰의 낱말("주차 가능")도 봅니다. 둘을 함께 보면 한쪽만 볼 때 놓치는 집을 덜 놓칩니다. 하이브리드 검색은 키워드 검색과 의미 검색의 점수를 섞습니다.
1번과 2번에서 키워드 점수(TF-IDF)와 의미 점수(임베딩)를 따로 구합니다. 두 점수는 크기가 달라서, 3번에서 질문마다 가장 작은 값을 0, 가장 큰 값을 1 로 맞춥니다. 4번에서 의미 점수에 α, 키워드 점수에 1 - α 를 곱해 더하고 높은 순서로 줄 세웁니다. 여기서 별점이 의미 점수, 리뷰 낱말이 키워드 점수입니다.
섞는 두 방법
| 방법 | 섞는 것 | 좋은 점 | 아쉬운 점 |
|---|---|---|---|
| 가중 합 | 0~1 로 맞춘 점수 × 무게 | α 로 비중을 조절한다 | α 를 질문 묶음으로 골라야 한다 |
| RRF | 순위의 역수 1/(60 + 순위) | 점수 크기를 맞출 필요가 없다 | 한쪽이 약하면 그 순위도 똑같이 섞인다 |
점수 섞어 보기
import numpy as np
kw = np.array([0.30, 0.05, 0.10, 0.00]) # 조각 4개의 키워드 점수(설명용 숫자)
sem = np.array([0.80, 0.86, 0.79, 0.78]) # 의미 점수: 고만고만하게 높다
def minmax(a):
"""가장 작은 값을 0, 가장 큰 값을 1 로 맞춘다"""
return (a - a.min()) / (a.max() - a.min())
for alpha in [0.0, 0.5, 0.7, 1.0]:
mixed = alpha * minmax(sem) + (1 - alpha) * minmax(kw)
print(alpha, mixed.round(2), "1등:", int(mixed.argmax()))α 가 0 이면 키워드만 보아 0번이, 1 이면 의미만 보아 1번이 1등입니다. 0.5 에서는 0번(0.63)이 1번(0.58)을 조금 앞서고, 0.7 에서는 1번(0.75)이 0번(0.48)을 앞섭니다. α 를 어디에 두느냐에 따라 순위가 바뀌므로, 정답 표로 재서 고릅니다(실습 6).
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
kw, sem | 조각 4개의 키워드 점수와 의미 점수다. kw 는 keyword(키워드), sem 은 semantic(의미)의 앞 글자다 |
a.min(), a.max() | 배열에서 가장 작은 값과 가장 큰 값이다 |
(a - a.min()) / (a.max() - a.min()) | min-max 맞추기다. 가장 작은 값은 0, 가장 큰 값은 1, 나머지는 그 사이의 비율로 바뀐다 |
alpha * minmax(sem) + (1 - alpha) * minmax(kw) | 가중 합이다. 의미 점수에 α, 키워드 점수에 1 - α 만큼 무게를 준다. alpha 는 그리스 글자 α 의 이름이다 |
mixed.argmax() | 섞은 점수가 가장 큰 조각의 번호를 돌려준다 |
이 코드가 뜻하는 것
맛집 고르기로 보면, 별점(의미 점수)과 리뷰 낱말 점수(키워드 점수)를 같은 100점 만점으로 바꾼 뒤 "별점 70%, 리뷰 30%"처럼 섞어 1등 집을 정하는 일입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
kw = ..., sem = ... | 두 검색의 점수를 적는다 | 리뷰 점수와 별점 |
def minmax(a) | 점수를 0~1 로 맞추는 함수 | 서로 다른 만점을 같은 만점으로 바꾼다 |
for alpha in [...] | 무게를 네 가지로 바꿔 본다 | 별점을 얼마나 믿을지 바꿔 본다 |
mixed = ... | 무게대로 섞은 점수 | 최종 점수 |
print(..., mixed.argmax()) | 1등 조각의 번호를 찍는다 | 오늘 갈 집 |
의미 점수(0.78~0.86)는 차이가 작아 그대로 더하면 키워드 점수의 차이에 묻히거나, 반대로 크기가 커서 키워드 점수를 덮습니다. 0~1 로 맞추면 두 점수가 같은 눈금에서 겨룹니다.
α 0.7 한 번만 손으로 따라가기
섞는 셈을 단계마다 찍어 봅니다.
import numpy as np
kw = np.array([0.30, 0.05, 0.10, 0.00])
sem = np.array([0.80, 0.86, 0.79, 0.78])
def minmax(a):
return (a - a.min()) / (a.max() - a.min())
print("키워드 0~1:", minmax(kw).round(3))
print("의미 0~1:", minmax(sem).round(3))
alpha = 0.7
print("의미 × 0.7 :", (alpha * minmax(sem)).round(3))
print("키워드 × 0.3:", ((1 - alpha) * minmax(kw)).round(3))
mixed = alpha * minmax(sem) + (1 - alpha) * minmax(kw)
print("합 :", mixed.round(3))키워드 0~1: [1. 0.167 0.333 0. ]
의미 0~1: [0.25 1. 0.125 0. ]
의미 × 0.7 : [0.175 0.7 0.088 0. ]
키워드 × 0.3: [0.3 0.05 0.1 0. ]
합 : [0.475 0.75 0.188 0. ]| 결과 | 읽는 법 |
|---|---|
| 키워드 0~1 | 가장 큰 0.30 이 1, 가장 작은 0.00 이 0 이 되었습니다. 0.05 는 0.167, 0.10 은 0.333 입니다 |
| 의미 0~1 | 0.78~0.86 의 좁은 차이가 0~1 로 넓어졌습니다. 1번 조각(0.86)이 1 입니다 |
| 의미 × 0.7, 키워드 × 0.3 | 무게를 곱한 몫입니다. 의미 쪽은 최대 0.7, 키워드 쪽은 최대 0.3 까지만 보탭니다 |
| 합 | 1번 조각이 0.75 로 1등, 0번 조각이 0.475 로 2등입니다. 위 코드의 α 0.7 줄(0.75, 0.48)과 같은 값입니다 |
0번 조각은 키워드에서 1등(1.0)이었지만 무게 0.3 이 곱해져 0.3 만 보탰고, 의미 쪽에서 0.175 를 더해 0.475 에 그쳤습니다. α 가 클수록 의미 검색의 1등이 이기기 쉽다는 것을 숫자로 볼 수 있습니다.
주의할 점
- 하이브리드가 늘 낫지는 않습니다. 한쪽 검색이 이미 훨씬 좋으면, 섞어서 오히려 첫째 자리가 나빠질 수 있습니다. 섞기 전후를 같은 질문 묶음으로 잽니다.
- α 를 고른 질문 묶음으로만 성적을 보고하면 점수가 부풀려집니다. 질문이 넉넉하면 고르는 데 쓰는 질문과 성적을 보고하는 질문을 나눕니다(1장의 검증용과 시험용).