TestForge Blog
← 전체 포스트

RAG 개발 3편 - Retrieval, Hybrid Search, Reranking으로 검색 품질 높이기

RAG에서 검색 품질은 답변 품질을 거의 결정합니다. dense retrieval, BM25, hybrid search, query rewrite, metadata filtering, reranking을 어떻게 조합해야 하는지 단계별로 설명합니다.

TestForge Team ·

검색 품질이 곧 RAG 품질이다

RAG 시스템에서 LLM은 가져온 문서를 바탕으로 답을 조합합니다. 즉, 처음에 잘못 가져오면 뒤에서 아무리 프롬프트를 잘 써도 한계가 있습니다.

그래서 검색 파이프라인은 아래 질문에 답할 수 있어야 합니다.

  • 어떤 질문에 어떤 문서를 가져오는가
  • 왜 그 문서를 top-k에 올렸는가
  • 잘못된 검색은 어디서 발생하는가

Dense Retrieval만으로 충분할까

벡터 검색은 문장 의미를 잘 잡지만, 아래 경우에는 약할 수 있습니다.

  • 에러 코드
  • API path
  • 클래스/함수 이름
  • 정확한 제품명
  • 약어와 내부 용어

예:

  • ERR_AUTH_401
  • /v1/tokens/refresh
  • Spring Cloud Gateway

이런 쿼리는 BM25 같은 키워드 기반 검색이 더 강할 때가 많습니다.

그래서 Hybrid Search가 필요하다

Hybrid Search는 보통 두 결과를 결합합니다.

  • sparse retrieval: BM25
  • dense retrieval: vector similarity

예시 흐름:

User Query
 -> Query normalization
 -> BM25 top-k
 -> Vector top-k
 -> Merge
 -> Rerank
 -> Final context

이 방식의 장점은 정확한 토큰과 의미 유사성을 모두 잡을 수 있다는 점입니다.

Query Rewrite는 생각보다 큰 차이를 만든다

사용자 질문은 종종 너무 짧고 추상적입니다.

예:

  • “로그인 안됨”
  • “배포 에러”
  • “권한 문제”

이 상태로 검색하면 결과가 퍼집니다.

Rewrite 예:

원문: "권한 문제"
변환: "AWS IAM 또는 Kubernetes RBAC 권한 부족으로 요청이 거부되는 경우의 원인과 해결 방법"

단, rewrite는 과하게 확장하면 원문 의도를 왜곡할 수 있으므로 주의해야 합니다.

metadata filtering은 필수다

RAG 검색은 “모든 문서 중 유사한 것”만 찾는 방식으로 끝나면 안 됩니다.

같이 고려할 필터:

  • 언어
  • 제품
  • 카테고리
  • 문서 공개 범위
  • 최신성
  • 사용자 권한

예:

search(
    query="토큰 재발급 방법",
    filters={
        "language": "ko",
        "product": "console",
        "visibility": "public"
    }
)

필터가 없으면 내부 문서가 외부 답변에 섞이거나, 오래된 버전 문서가 상위에 뜰 수 있습니다.

top-k는 얼마나 가져와야 할까

많이 가져온다고 좋은 게 아닙니다.

일반적으로:

  • retrieval 단계: top 10~30
  • rerank 후 최종 전달: top 3~8

이 정도에서 출발하는 경우가 많습니다.

너무 적으면 놓치고, 너무 많으면 노이즈가 섞입니다.

Reranking은 왜 강력한가

retrieval은 대략 관련된 문서를 찾는 단계고, reranking은 질문에 가장 직접적으로 답하는 순서로 다시 정렬하는 단계입니다.

예를 들어 top-10에 좋은 문서가 들어왔더라도 7위에 있으면 LLM 컨텍스트에 못 들어갈 수 있습니다. reranker는 이 순서를 바로잡아 줍니다.

특히 긴 문서, 중복 chunk, 비슷한 표현이 많은 도메인에서 효과가 큽니다.

검색 파이프라인 예시

def retrieve_context(query: str):
    rewritten = rewrite_query(query)

    sparse_hits = bm25_search(rewritten, top_k=10)
    dense_hits = vector_search(rewritten, top_k=10)

    merged = merge_hits(sparse_hits, dense_hits)
    reranked = rerank(query, merged)

    return reranked[:5]

핵심은 원문 질문을 기준으로 최종 순서를 정하는 것입니다. rewrite 결과만 믿고 끝내면 원질문과 어긋날 수 있습니다.

duplicate suppression도 필요하다

같은 문서의 인접 chunk들이 top 결과에 여러 개 뜨는 경우가 많습니다.

문제:

  • 컨텍스트가 한 문서로 과도하게 쏠림
  • 다양한 근거를 못 가져옴
  • 토큰 낭비

해결 방법:

  • 같은 doc_id에서 최대 n개까지만 허용
  • 인접 chunk 병합
  • MMR(Maximal Marginal Relevance) 적용

검색 실패 유형을 분류해보자

운영 중에는 검색 실패를 유형별로 나눠봐야 합니다.

1. recall failure

정답 문서 자체를 못 가져옴

원인:

  • 잘못된 청킹
  • 부정확한 query
  • 임베딩 한계
  • 필터 과도

2. ranking failure

정답 문서는 가져왔지만 순위가 낮음

원인:

  • reranking 부재
  • merge 로직 문제
  • 중복 결과 편향

3. grounding failure

검색은 맞았는데 답변 생성이 엉뚱함

원인:

  • 프롬프트 문제
  • 컨텍스트 정리 부족
  • 너무 많은 chunk 전달

추천하는 발전 순서

처음부터 모든 걸 붙이지 말고 이렇게 가는 편이 좋습니다.

  1. vector retrieval만으로 시작
  2. metadata filter 추가
  3. BM25 결합
  4. query rewrite 추가
  5. reranker 추가
  6. duplicate suppression 추가

이 순서대로 가야 어느 단계가 실제 성능 개선을 만들었는지 판단하기 쉽습니다.

로그로 꼭 남겨야 할 것

  • 원문 쿼리
  • rewrite 결과
  • retrieval 결과 top-k
  • 최종 rerank 순위
  • 선택된 chunk와 doc_id
  • 누락된 expected 문서 여부

이 정보가 있어야 “왜 이 답변이 나왔는가”를 나중에 분석할 수 있습니다.

마무리

RAG 검색 품질을 높이는 핵심은 검색 단계를 하나로 보지 않는 것입니다.

  • 사용자 질문을 정리하고
  • 여러 검색 방식을 결합하고
  • 필터로 범위를 좁히고
  • reranking으로 최종 순서를 바로잡고
  • 중복을 제어해야 합니다

다음 글에서는 이렇게 가져온 문서를 바탕으로 실제 답변을 어떻게 생성할지, 프롬프트, citation, fallback, answer formatting을 어떻게 설계해야 하는지 다루겠습니다.