logo

키워드 검색과 벡터 검색

같은 청크를 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)에서 다시 추림.

퀴즈

문제 1 / 5맞음: 0힌트: 0틀림: 0채점중: 0남음: 5

질문과 청크에 같은 단어가 얼마나 겹치는지로 점수를 매기는 검색 방법은?

  • ○BM25
  • ○임베딩 벡터 검색
  • ○코사인 유사도
  • ○Contextual Retrieval

퀴즈를 풀려면 대화형 기능을 불러와야 합니다.