RAG 기초 (6) - Retrieval Quality: 검색이 실패할 때
벡터 검색이 놓치는 질문 유형을 확인하고 BM25 하이브리드 검색, reranking, 질문 재작성으로 검색 품질을 올리는 방법을 다룹니다.
RAG 기초 시리즈의 6편입니다. 전체 목차는 0편에 있습니다.
벡터 검색이 놓치는 것
5편까지의 파이프라인은 대부분의 질문에 잘 답합니다. 실패하는 질문에는 유형이 있습니다.
정확한 문자열을 찾는 질문
1
2
search("주문번호 20260721-3312 상태 알려주세요")
# 관련 없는 chunk가 상위에 옵니다
embedding은 의미의 유사성을 재기 때문에 숫자 나열의 미세한 차이를 잘 구분하지 못합니다. 상품 코드, 주문번호, 오류 코드, 사람 이름처럼 정확히 일치해야 하는 대상에 약합니다.
질문에 정보가 부족한 경우
1
2
search("얼마예요?")
# 무엇의 가격인지 알 수 없으므로 아무 chunk나 옵니다
여러 문서에 걸친 질문
1
2
search("골드 등급인데 25,000원어치 사면 배송비 내나요?")
# 배송 정책과 회원 등급 두 문서가 모두 필요합니다
k를 늘리면 세 번째는 일부 완화되지만 첫 번째와 두 번째는 그대로입니다. 방법이 다릅니다.
키워드 검색을 함께 쓴다
첫 번째 유형은 옛날 방식이 강합니다. BM25는 단어 일치 정도로 문서 순위를 매기는 알고리즘이고, 정확한 문자열에 강합니다.
1
2
3
4
5
6
7
8
9
10
from rank_bm25 import BM25Okapi
chunks = build_chunks()
tokenized = [c["text"].split() for c in chunks] # 공백 단위 토큰화
bm25 = BM25Okapi(tokenized)
def keyword_search(query: str, k: int = 3):
scores = bm25.get_scores(query.split())
top = sorted(range(len(scores)), key=lambda i: -scores[i])[:k]
return [chunks[i] for i in top]
한국어는 조사가 붙어서 공백 분리만으로는 “배송비가”와 “배송비를”이 다른 단어가 됩니다. 정확도를 더 올리려면 kiwipiepy 같은 형태소 분석기로 토큰화합니다. 다만 고유명사와 숫자를 찾는 목적에는 공백 분리로도 효과가 있습니다.
두 결과를 합치기
벡터 검색과 키워드 검색의 점수는 스케일이 달라서 직접 더할 수 없습니다. 순위만 가지고 합치는 RRF(Reciprocal Rank Fusion)를 씁니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
def hybrid_search(query: str, k: int = 3, rrf_k: int = 60):
vector_hits = search(query, k=10) # 4편의 벡터 검색
keyword_hits = keyword_search(query, k=10)
scores = {}
for rank, hit in enumerate(vector_hits, start=1):
scores[hit["id"]] = scores.get(hit["id"], 0) + 1 / (rrf_k + rank)
for rank, hit in enumerate(keyword_hits, start=1):
scores[hit["id"]] = scores.get(hit["id"], 0) + 1 / (rrf_k + rank)
by_id = {h["id"]: h for h in vector_hits + keyword_hits}
top = sorted(scores, key=lambda i: -scores[i])[:k]
return [by_id[i] for i in top]
원리는 단순합니다. 어느 쪽에서든 상위에 오른 chunk가 높은 점수를 받고, 양쪽 모두에서 상위인 chunk가 가장 높습니다. rrf_k는 순위 차이의 영향을 완만하게 만드는 상수이고 60이 관례적으로 쓰입니다.
각 검색기에서 k보다 넉넉히(위 코드에서는 10개) 가져와 합친 뒤 상위 k개를 남기는 것이 중요합니다. 3개씩만 가져오면 합칠 후보가 없습니다.
순위를 다시 매기기
한 단계 더 있습니다. 벡터 검색은 질문과 chunk를 각각 따로 임베딩해서 비교합니다. 빠른 대신 정밀하지 않습니다. reranker는 질문과 chunk를 함께 읽고 관련도를 점수로 내는 모델입니다. 느린 대신 정확합니다.
두 모델을 순서대로 씁니다. 빠른 검색으로 후보를 20개로 줄이고, 느린 모델로 그 20개의 순위를 다시 매겨 상위 3개를 남깁니다.
flowchart LR
Q[질문] --> V[벡터 검색]
Q --> B[BM25 검색]
V --> R[RRF 병합]
B --> R
R --> C[후보 20개]
C --> RR[reranker]
RR --> T[상위 3개]
T --> P[프롬프트]
1
2
3
4
5
6
7
8
9
10
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def search_with_rerank(query: str, k: int = 3, candidates: int = 20):
hits = hybrid_search(query, k=candidates)
pairs = [(query, h["text"]) for h in hits]
scores = reranker.predict(pairs)
ranked = sorted(zip(hits, scores), key=lambda x: -x[1])
return [h for h, _ in ranked[:k]]
reranker는 chunk 개수만큼 모델을 돌리므로 후보를 무작정 늘리면 느려집니다. 20에서 50 사이에서 시작해 지연 시간과 정확도를 보고 조정합니다.
질문을 다시 쓰기
두 번째 유형(질문에 정보가 부족한 경우)은 검색 전에 질문을 다듬어 해결합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
REWRITE_SYSTEM = """사용자 질문을 검색에 쓸 수 있게 다시 쓴다.
대화 맥락에 있는 생략된 주어와 대상을 채워 넣는다.
질문 형태를 유지하고 한 문장으로 쓴다. 다른 말은 붙이지 않는다."""
def rewrite(question: str, history: str) -> str:
message = client.messages.create(
model="claude-haiku-4-5", # 짧고 단순한 작업이라 작은 모델로 충분
max_tokens=256,
system=REWRITE_SYSTEM,
messages=[{"role": "user", "content":
f"<대화>\n{history}\n</대화>\n\n<질문>\n{question}\n</질문>"}],
)
return text_of(message).strip()
history = "사용자: 제주도로 주문하려는데요\n담당자: 네, 도와드리겠습니다."
print(rewrite("얼마예요?", history))
# 제주도로 주문할 때 배송비가 얼마인가요?
멀티턴 대화에서는 이 단계가 없으면 검색이 거의 동작하지 않습니다. “그럼 그건요?” 같은 질문을 그대로 임베딩해 봐야 아무것도 나오지 않습니다.
한 걸음 더 나아가 질문 하나를 여러 개로 확장한 뒤 각각 검색해 결과를 합치는 방법도 있습니다. 세 번째 유형(여러 문서에 걸친 질문)에 효과가 있습니다. 대신 호출과 검색이 배로 늘어납니다.
무엇부터 적용하나
전부 붙이면 느리고 비쌉니다. 실패 유형을 확인하고 필요한 것만 씁니다.
| 증상 | 먼저 볼 것 |
|---|---|
| 코드, 번호, 고유명사를 못 찾음 | BM25 하이브리드 |
| 관련 chunk가 상위 10위 안에는 있는데 3위 안에 없음 | reranker |
| 멀티턴에서 후속 질문이 실패함 | 질문 재작성 |
| 질문이 특정 문서로 한정 가능함 | 메타데이터 필터 (4편) |
| chunk 안에 답이 반쪽만 있음 | chunk 크기와 overlap (2편) |
| 검색은 맞는데 답이 틀림 | 검색이 아니라 프롬프트 문제 (5편) |
마지막 줄이 중요합니다. 무엇을 고쳐야 하는지 알려면 검색과 생성을 나눠 채점해야 하고, 그 방법이 다음 편입니다.