포스트

RAG 기초 (6) - Retrieval Quality: 검색이 실패할 때

벡터 검색이 놓치는 질문 유형을 확인하고 BM25 하이브리드 검색, reranking, 질문 재작성으로 검색 품질을 올리는 방법을 다룹니다.

RAG 기초 (6) - Retrieval Quality: 검색이 실패할 때

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편)

마지막 줄이 중요합니다. 무엇을 고쳐야 하는지 알려면 검색과 생성을 나눠 채점해야 하고, 그 방법이 다음 편입니다.

다음 글: RAG 기초 (7) - Evaluation and Operations: 나눠서 채점하기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.