Published on

Pinecone·Milvus RAG 정확도↑ - 하이브리드+재랭킹

Authors

RAG의 품질은 결국 검색(retrieval)이 좌우합니다. LLM이 아무리 똑똑해도, 컨텍스트에 정답 근거가 없으면 그럴듯한 답을 만들거나(환각) 엉뚱한 문서를 근거로 답을 내립니다. Pinecone·Milvus 같은 벡터 DB를 도입해도 “왜 특정 질문에서만 자꾸 빗나가지?”라는 문제가 남는 이유는 대개 두 가지입니다.

  1. 벡터 검색만으로는 키워드/희귀 토큰/정확한 문자열 매칭에 약함
  2. Topk 후보 내부의 순서가 ‘답변에 유리한 순서’가 아님

이 글에서는 RAG 정확도를 체감 수준으로 끌어올리는 가장 재현성 높은 조합인 하이브리드 검색(lexical+vector) + **재랭킹(reranking)**을 Pinecone·Milvus 관점에서 실전적으로 정리합니다.

또한 Milvus 자체 튜닝으로 리콜과 지연을 함께 잡는 방법은 아래 글이 큰 도움이 됩니다.


왜 하이브리드 검색이 RAG 정확도를 올리나

벡터 검색(semantic)은 “의미가 비슷한 문서”를 잘 찾지만, 다음 케이스에서 자주 놓칩니다.

  • 정확한 키워드가 중요한 질문: 에러 코드, 함수명, 설정 키, 제품 SKU, 규정 조항 번호
  • 희귀 토큰: 사내 약어, 프로젝트 코드명, 고객명
  • 부정/조건: “A가 아닌 B”, “X일 때만” 같은 조건은 임베딩에서 희미해지기 쉬움

반대로 BM25 같은 lexical 검색은 문자열 단서를 잘 잡지만, 동의어/표현 변형에는 약합니다.

따라서 실전에서는 둘을 섞습니다.

  • 1차 후보군: BM25 + Vector를 합쳐 N개 생성
  • 2차 정렬: cross-encoder 재랭커로 상위 k를 “질문에 답이 있는 순서”로 재정렬

이 조합이 좋은 이유는, 하이브리드로 누락을 줄이고(recall↑), 재랭킹으로 오답 근거를 줄이는(precision↑) 구조이기 때문입니다.


전체 파이프라인: Retrieve → Merge → Rerank → Generate

아래는 가장 흔히 쓰는 형태의 파이프라인입니다.

  1. Query 전처리(정규화, 약어 확장, 언어 감지)
  2. Lexical 검색(BM25 등)으로 top_n_lex
  3. Vector 검색으로 top_n_vec
  4. 후보 병합 및 중복 제거
  5. 재랭킹(cross-encoder)으로 top_k 선택
  6. 컨텍스트 압축(필요 시)
  7. LLM 생성

핵심 파라미터 감각은 다음과 같습니다.

  • top_n_lex: 50~200
  • top_n_vec: 50~200
  • merge 후 rerank 입력: 100~300 (재랭커 비용/지연과 트레이드오프)
  • 최종 top_k: 5~15

Pinecone에서 하이브리드 검색 구현 패턴

Pinecone은 환경에 따라 하이브리드를 구현하는 방식이 달라집니다.

  • (패턴 A) 별도 BM25 인덱스(예: Elasticsearch/OpenSearch) + Pinecone 벡터를 애플리케이션에서 결합
  • (패턴 B) Pinecone의 기능(플랜/제품 구성에 따라)을 활용해 sparse+ dense를 함께 저장하고 검색

실무에서는 패턴 A가 가장 범용적입니다. BM25는 OpenSearch로, 벡터는 Pinecone으로 두고 결과를 합칩니다.

패턴 A: OpenSearch(BM25) + Pinecone(Vector) 병합

아래 예시는 “각 검색에서 점수를 정규화한 뒤 가중합”으로 합치는 단순하지만 강력한 방식입니다.

from dataclasses import dataclass
from typing import List, Dict
import numpy as np

@dataclass
class Hit:
    doc_id: str
    score: float
    source: str  # "lex" or "vec"


def minmax_normalize(scores: List[float]) -> List[float]:
    if not scores:
        return []
    s = np.array(scores, dtype=float)
    if s.max() == s.min():
        return [1.0 for _ in scores]
    return ((s - s.min()) / (s.max() - s.min())).tolist()


def merge_hits(lex_hits: List[Hit], vec_hits: List[Hit], w_lex=0.45, w_vec=0.55) -> List[Hit]:
    lex_norm = minmax_normalize([h.score for h in lex_hits])
    vec_norm = minmax_normalize([h.score for h in vec_hits])

    merged: Dict[str, float] = {}

    for h, ns in zip(lex_hits, lex_norm):
        merged[h.doc_id] = merged.get(h.doc_id, 0.0) + w_lex * ns

    for h, ns in zip(vec_hits, vec_norm):
        merged[h.doc_id] = merged.get(h.doc_id, 0.0) + w_vec * ns

    out = [Hit(doc_id=k, score=v, source="merged") for k, v in merged.items()]
    out.sort(key=lambda x: x.score, reverse=True)
    return out

이 merge 단계에서 자주 하는 실전 보정:

  • 필드 부스팅: 제목/헤더/H1에 매칭되면 가산점
  • 최신성/권위 가중치: 문서 업데이트 날짜, 승인 여부, 소스 신뢰도
  • 언어/도메인 필터: 질문이 한국어면 한국어 문서 우선

Milvus에서 하이브리드 검색 구현 패턴

Milvus는 벡터 DB이므로 “BM25를 Milvus가 대신해준다”기보다는, 보통 아래 둘 중 하나로 갑니다.

  • (패턴 A) BM25는 외부 엔진(OpenSearch), 벡터는 Milvus, 애플리케이션에서 결합
  • (패턴 B) Milvus에 텍스트 관련 메타데이터를 넣고 필터링+벡터로 좁힌 뒤 재랭킹에 더 의존

대규모 텍스트 코퍼스에서 lexical이 중요하면 패턴 A가 안전합니다.

Milvus 벡터 검색 예시(파이썬)

from pymilvus import MilvusClient

client = MilvusClient(uri="http://localhost:19530")

res = client.search(
    collection_name="docs",
    data=[query_embedding],
    anns_field="embedding",
    limit=100,
    output_fields=["title", "text", "source", "updated_at"],
    search_params={"metric_type": "COSINE", "params": {"nprobe": 16}},
    filter='source in ["kb", "runbook"]'
)

# res[0] 형태로 hits 접근

여기서 nprobe 같은 파라미터는 recall/latency에 직접 영향을 줍니다. 인덱스가 IVF_PQ라면 특히 튜닝 여지가 큽니다. (위에서 링크한 IVF_PQ 튜닝 글 참고)


재랭킹이 정확도를 올리는 메커니즘

하이브리드로 후보군을 넓히면 recall은 좋아지지만, 상위에 “그럴듯한” 문서가 섞일 수 있습니다. 재랭킹은 여기서 질문-문서 쌍을 함께 읽고 관련도를 다시 매깁니다.

  • bi-encoder(임베딩) 기반 검색: qd를 따로 인코딩하고 거리 계산
  • cross-encoder 재랭커: qd를 한 번에 넣고 “이 문서가 질문에 답이 되는가”를 직접 점수화

그래서 재랭커는 보통 Topk순서를 바꾸는 것만으로도 정답률이 크게 오릅니다.

재랭커 선택지

  • OpenAI/타사 rerank API
  • 로컬 모델(예: bge-reranker, cross-encoder/ms-marco-* 계열)

운영 관점에서 중요한 체크리스트:

  • 입력 토큰 비용과 지연(특히 top_n이 커질수록)
  • 한국어 성능(다국어 reranker 권장)
  • 도메인 적합성(법률/의료/코드 등 특수 도메인은 파인튜닝 고려)

로컬 cross-encoder 재랭킹 예시

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")  # 예시

pairs = [(query, doc_text) for doc_text in candidate_texts]
# scores: 문서별 관련도 점수
scores = reranker.predict(pairs)

ranked = sorted(zip(candidate_ids, scores), key=lambda x: x[1], reverse=True)
final_ids = [doc_id for doc_id, _ in ranked[:10]]

실전 팁:

  • 재랭커 입력 텍스트는 원문 전체가 아니라 chunk 본문 + 제목 + 섹션 헤더 정도로 구성하면 효율이 좋습니다.
  • chunk가 너무 길면 앞부분만 들어가서 핵심이 잘릴 수 있으니, 질문 키워드 주변 윈도잉 또는 컨텍스트 압축을 같이 고려하세요.

하이브리드 점수 결합: “가중합”만으로 충분한가

많은 팀이 처음에는 w_lex * score_lex + w_vec * score_vec로 시작합니다. 이 방식은 단순하지만, 다음 조건을 만족하면 꽤 오래 갑니다.

  • 점수 정규화(min-max 또는 z-score)를 한다
  • 검색 결과 분포가 극단적으로 한쪽에 쏠리지 않는다
  • 재랭커가 뒤에서 정렬을 교정한다

다만 트래픽이 커지고 데이터가 복잡해지면 아래 방식이 더 안정적입니다.

  • RRF(Reciprocal Rank Fusion): 점수 대신 순위 기반 결합
  • 학습 기반 랭킹(LTR): 클릭/정답 데이터가 있을 때

RRF 예시

def rrf_fuse(rank_lists, k=60):
    # rank_lists: [ [doc_id1, doc_id2, ...], ... ]
    scores = {}
    for rl in rank_lists:
        for r, doc_id in enumerate(rl, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + r)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

RRF는 각 엔진의 스코어 스케일 문제에서 비교적 자유롭고, 하이브리드 첫 도입에 특히 무난합니다.


Chunking이 하이브리드+재랭킹 성능을 망치는 흔한 패턴

검색/재랭킹을 잘 붙였는데도 정확도가 안 오르면, 의외로 chunking이 원인인 경우가 많습니다.

  • 너무 작은 chunk: 문맥이 부족해 재랭커가 판단을 못함
  • 너무 큰 chunk: 핵심이 뒤에 있는데 입력이 앞에서 잘림
  • 경계가 나쁨: 표/코드/목록이 중간에 잘려 의미 손상

권장 접근:

  • 문서 구조 기반 chunking(헤더 단위) + 토큰 상한
  • overlap은 10%~20% 정도에서 시작
  • 코드/표는 별도 블록으로 보존

운영에서 중요한 평가 지표: “정답률”을 어떻게 재는가

RAG 품질 개선은 감으로 하면 끝이 없습니다. 최소한 아래를 분리해 측정해야 합니다.

  • Retrieval recall: 정답 문서가 후보군 N에 들어왔는가
  • Rerank precision: 최종 k에 정답이 들어왔는가
  • Answer correctness: 실제 답이 맞는가

추천하는 간단한 오프라인 평가 루프:

  1. 질문-정답-근거 문서(또는 근거 스팬)로 evaluation set 구성
  2. 설정을 바꿔가며 top_n_lex, top_n_vec, merge 방식, 재랭커 모델 비교
  3. 실패 케이스를 유형화(키워드 miss, 최신 문서 미반영, chunk 경계 문제 등)

Milvus를 쓰는 경우, 인덱스/파라미터 튜닝이 recall에 미치는 영향이 커서 “검색 알고리즘 튜닝”과 “하이브리드/재랭킹”을 함께 최적화하는 게 좋습니다.


비용·지연 최적화: 재랭킹을 싸게 쓰는 방법

재랭킹은 정확도를 올리지만 비용과 지연이 늘 수 있습니다. 아래는 체감 효과가 큰 순서대로의 최적화 체크리스트입니다.

1) 재랭킹 입력을 줄이기

  • merge 후보를 300에서 150으로 줄여도 성능이 유지되는지 확인
  • 문서 전체 대신 “제목+핵심 단락”만 전달

2) 2단 재랭킹

  • 1차: 가벼운 reranker 또는 규칙 기반 필터
  • 2차: 무거운 cross-encoder를 top_50에만 적용

3) 캐시

  • 동일/유사 질의에 대한 rerank 결과 캐시
  • 문서 업데이트 시 캐시 무효화 전략

4) 인프라 자동 확장

재랭킹을 로컬로 돌리면 GPU/CPU 오토스케일이 병목을 줄입니다. 쿠버네티스 환경이라면 노드 오토스케일링으로 비용을 관리할 수 있습니다.


실전 권장 설정(초기값)

Pinecone·Milvus 어느 쪽이든 아래 조합으로 시작하면 실패 확률이 낮습니다.

  • Lexical: BM25 top_n_lex = 100
  • Vector: top_n_vec = 100
  • Fusion: RRF 또는 min-max 정규화 가중합
  • Rerank: cross-encoder로 merge 후보 200개를 재랭킹
  • Final context: top_k = 8 정도
  • Chunk: 헤더 기반 + 토큰 상한 + 15% overlap

그리고 꼭 오프라인 평가로 “어떤 질문에서 좋아졌고, 어떤 질문에서 나빠졌는지”를 남기세요. 하이브리드/재랭킹은 만능이 아니라, 파라미터와 데이터 형태에 따라 실패 모드가 분명히 존재합니다.


마무리: 정확도는 recallranking의 곱이다

RAG 정확도 개선을 한 문장으로 요약하면 다음입니다.

  • 하이브리드 검색으로 **정답이 후보군에 들어올 확률(recall)**을 올리고
  • 재랭킹으로 **정답이 상위에 배치될 확률(precision)**을 올린다

Pinecone든 Milvus든, “벡터만 잘 넣으면 된다”는 단계에서 벗어나 검색을 제품처럼 다듬는 순간 정확도가 올라갑니다. 다음 단계로는 실패 케이스를 모아 query rewriting, 도메인별 부스팅, LTR까지 확장하면 됩니다.