키워드 검색과 벡터 검색
같은 청크를 BM25 키워드 검색과 임베딩 벡터 검색으로 찾아 결과를 비교하고, 두 순위를 RRF로 합친다.
- 검색기(retriever): 질문을 받아 관련 청크를 순서대로 돌려주는 부분. RAG 답변의 품질은 검색 결과에 크게 좌우됨. 필요한 청크를 못 찾으면 모델도 답할 수 없음.
- 두 가지 방식:
- 희소 검색(sparse retrieval): 질문과 청크에 같은 단어가 얼마나 겹치는지로 점수 계산. 대표: BM25.
- 밀집 검색(dense retrieval): 질문과 청크를 임베딩 벡터로 바꾸고 벡터가 얼마나 비슷한지로 점수 계산. 벡터 검색이라고도 함.
- 희소·밀집: 단어마다 차원을 두면 대부분이 0인 긴 벡터(희소), 임베딩은 모든 차원에 값이 있는 짧은 벡터(밀집).
실습 준비
- 실습 자료 다운로드: mindmall_guide.py. 현재 폴더에 업로드. 안내서 원문이 문자열 변수
guide에 들어 있음. - API 키를
openrouter-key.txt에 저장하고, 현재 폴더에 업로드. 임베딩도 OpenRouter로 계산.
!pip install -q openai rank_bm25
from pathlib import Path
import numpy as np
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=Path("openrouter-key.txt").read_text().strip(),
)
from mindmall_guide import guide
청크: 문단 단위로 나누고, 문단 앞에 절 제목을 붙임. 청킹에서 본 맥락이 빠지는 문제를 줄이는 간단한 방법.
def chunk_with_heading(text):
chunks = []
for section in text.split("\n## ")[1:]: # 머리말 제외
title, body = section.split("\n", 1) # 첫 줄은 절 제목
chunks += [f"[{title}] {p.strip()}" for p in body.split("\n\n") if p.strip()]
return chunks
chunks = chunk_with_heading(guide)
len(chunks)
실행 결과
22
BM25 키워드 검색
- TF-IDF를 개선한 점수. 질문의 단어가 청크에 자주 나올수록, 그 단어가 다른 청크에는 드물수록 높은 점수.
- 같은 단어가 여러 번 나와도 점수가 끝없이 늘지 않게 조정하고, 긴 청크가 유리하지 않도록 길이를 보정.
- 별도의 모델이나 학습이 필요 없고 빠름.
토큰화: 한국어는 조사와 어미가 붙어 "반품"과 "반품하면"이 다른 단어가 됨. 여기서는 어절을 두 글자씩 겹쳐 자르는 바이그램(bigram)으로 부분 일치를 잡음. 형태소 분석기를 써도 됨.
def tokenize(text): # 어절마다 두 글자씩 겹쳐 자름
tokens = []
for word in text.split():
tokens += [word[i:i + 2] for i in range(max(1, len(word) - 1))]
return tokens
tokenize("반품하면 배송비")
실행 결과
['반품', '품하', '하면', '배송', '송비']
from rank_bm25 import BM25Okapi
bm25 = BM25Okapi([tokenize(c) for c in chunks]) # 청크마다 토큰 목록으로 색인
def search_bm25(query, k=3): # 상위 k개의 (번호, 점수). 점수 0은 제외
scores = bm25.get_scores(tokenize(query))
top = np.argsort(-scores)[:k]
return [(int(i), round(float(scores[i]), 2)) for i in top if scores[i] > 0]
search_bm25("제주도 배송비")
실행 결과
[(7, 8.39), (10, 3.27), (15, 2.4)]
- 1위인 7번 청크는 제주도 추가 배송비를 다룬 배송 절의 문단.
임베딩 벡터 검색
- 임베딩: 텍스트를 의미가 비슷하면 가까운 벡터로 바꾼 것. 3주 임베딩을 이용한 평가에서 사용.
- 여기서는 OpenRouter의 임베딩 API 사용. 채팅과 같은
client로client.embeddings.create()호출. - 모형:
openai/text-embedding-3-small. 한국어를 포함한 여러 언어를 지원하고 값이 쌈. 다른 모형으로 바꾸면 청크도 다시 임베딩해야 함.
embedding_model = "openai/text-embedding-3-small"
def embed(texts):
response = client.embeddings.create(model=embedding_model, input=texts)
vectors = np.array([d.embedding for d in response.data])
return vectors / np.linalg.norm(vectors, axis=1, keepdims=True) # 길이를 1로
chunk_vectors = embed(chunks)
chunk_vectors.shape # (청크 수, 차원 수)
실행 결과
(22, 1536)
- 벡터의 길이를 1로 맞춰 두면, 두 벡터의 내적이 곧 코사인 유사도.
def search_dense(query, k=3): # 상위 k개의 (번호, 유사도)
scores = chunk_vectors @ embed([query])[0] # 모든 청크와 내적
top = np.argsort(-scores)[:k]
return [(int(i), round(float(scores[i]), 3)) for i in top]
search_dense("제주도 배송비")
실행 결과
[(7, 0.63), (6, 0.452), (8, 0.443)]
- 단어가 잘 겹치는 질문은 두 방법 모두 같은 청크를 찾음.
두 방법의 차이
표현이 다른 질문: "택배"는 안내서에 없는 말. 안내서는 "출고", "도착"이라고 씀.
query = "택배 언제 와요?"
print("BM25:", search_bm25(query))
print("벡터:", search_dense(query))
실행 결과
BM25: []
벡터: [(6, 0.423), (8, 0.381), (9, 0.308)]
- BM25: 겹치는 바이그램이 없어 모든 청크의 점수가 0. 찾은 청크가 없음.
- 벡터 검색: 택배가 오는 시점 = 출고와 도착이라는 의미를 잡아 6번 청크("평일 오후 2시까지 결제한 주문은 당일 출고한다. … 출고 후 보통 1-2일 안에 도착한다.")를 찾음.
- 반대로 전화번호("1588-0000")·제품 코드·약어처럼 의미보다 글자 자체가 중요한 문자열은 BM25가 강함. 임베딩 모형이 학습에서 거의 보지 못한 문자열은 의미를 잘 표현하지 못함.
query를 바꿔 직접 비교해 볼 것. - 벡터 검색도 만능은 아님. "포인트 언제 들어와요?"(안내서는 '적립금')처럼 동의어로 물으면 두 방법 모두 놓칠 수 있음. 검색 전후 최적화에서 보완.
| 방식 | 강점 | 약점 |
|---|---|---|
| BM25 | 고유명사·코드·숫자, 학습하지 않은 분야, 빠른 속도 | 표현이 다르면 못 찾음 |
| 벡터 검색 | 같은 뜻의 다른 표현, 동의어 | 드문 문자열, 임베딩 모형과 계산 비용 필요 |
순위 합치기: RRF
- 하이브리드 검색: 두 검색기의 결과를 합쳐 서로의 약점을 보완.
- 점수는 합치기 어려움. BM25 점수는 0에서 수십, 코사인 유사도는 -1에서 1로 척도가 다름.
- RRF(Reciprocal Rank Fusion): 점수 대신 순위만 사용. 각 검색기에서 r위인 청크에 k+r1점을 주고 더함. k는 보통 60.
def search_hybrid(query, k=3, rrf_k=60): # 두 검색기의 전체 순위를 RRF로 합침
fused = {}
for results in (search_bm25(query, len(chunks)), search_dense(query, len(chunks))):
for rank, (i, _) in enumerate(results, start=1):
fused[i] = fused.get(i, 0) + 1 / (rrf_k + rank)
top = sorted(fused.items(), key=lambda item: -item[1])[:k]
return [(i, round(score, 4)) for i, score in top]
search_hybrid("택배 언제 와요?")
실행 결과
[(6, 0.0164), (8, 0.0161), (9, 0.0159)]
- 두 검색기가 모두 높은 순위를 준 청크가 위로 올라옴.
- "택배" 질문에서는 BM25가 찾은 청크가 없으므로 벡터 검색의 순위가 그대로 남음. 점수 0인 청크까지 순위에 넣으면, 의미 없는 순서가 섞여 6번 청크가 밀려남.
search_bm25에서 점수 0을 뺀 이유. - 하이브리드가 모든 질문에서 낫지는 않지만, 여러 질문에 걸쳐 평균적으로 안정적.
- 실무에서는 두 검색기에서 각각 수십 개를 뽑아 RRF로 합친 뒤, 다음 단계(rerank)에서 다시 추림.
퀴즈
질문과 청크에 같은 단어가 얼마나 겹치는지로 점수를 매기는 검색 방법은?
퀴즈를 풀려면 대화형 기능을 불러와야 합니다.