Published on

Weaviate Hybrid Search RAG 튜닝 - BM25·벡터 가중치

Authors

서치 기반 RAG에서 “모델이 똑똑한데 답이 틀린” 상황의 상당수는 생성(LLM) 문제가 아니라 검색(리트리벌) 스코어링/가중치 문제입니다. Weaviate의 Hybrid Search는 BM25(키워드)와 벡터(의미) 검색을 결합해 이런 문제를 줄여주지만, 기본값만으로는 도메인/쿼리 분포에 맞지 않아 정확도와 재현율이 동시에 흔들릴 수 있습니다.

이 글에서는 Weaviate Hybrid Search에서 BM25·벡터 가중치(알파) 조정, 스코어 정규화 관점, RAG 파이프라인에서의 실측 기반 튜닝 루프를 다룹니다. 또한 “왜 이 문서가 뽑혔는지”를 설명 가능한 형태로 로그/평가하는 방법까지 함께 정리합니다.

대규모 벡터 인덱스에서 메모리/리소스 이슈가 먼저 터진다면, 하이브리드 이전에 인프라 안정화가 선행입니다. 유사한 증상 체크리스트는 FAISS RAG 메모리 폭증 OOM 해결 체크리스트도 참고할 만합니다.

Hybrid Search가 필요한 전형적인 RAG 실패 패턴

1) 키워드가 중요한 쿼리에서 벡터만 쓰면 생기는 문제

  • 제품 코드, 에러 코드, 약어, 버전 문자열 같은 정확 일치 토큰이 핵심일 때
  • 예: HTTP 429, EKS Karpenter, TS 5.6 satisfies 같은 쿼리

벡터 검색은 의미적으로 비슷한 문서를 잘 찾지만, 이런 “토큰 정확도”가 중요한 쿼리에서는 정확 일치 문서보다 주변 문서를 먼저 가져오는 경우가 있습니다.

2) 자연어 질문에서 BM25만 쓰면 생기는 문제

  • 동의어/표현 변형이 많은 질문
  • 문서가 길고, 질문과 동일한 키워드가 문서에 없더라도 의미상 답이 있는 경우

BM25는 텍스트 일치에 강하지만 의미적 유사성은 약합니다. 그래서 “질문과 단어가 다르지만 답은 같은” 문서를 놓칩니다.

3) 하이브리드인데도 품질이 안 나오는 이유

하이브리드는 만능이 아니라, 아래가 정리되지 않으면 오히려 불안정해집니다.

  • 청크 전략(너무 길거나 너무 짧음)
  • 필터/스코프(테넌트/버전/언어/권한) 누락
  • 알파(가중치) 기본값이 쿼리 타입과 안 맞음
  • topK가 너무 작거나 너무 큼
  • 스코어 정규화가 기대와 다르게 동작

이 글의 초점은 그중에서도 alpha를 중심으로 한 가중치 튜닝입니다.

Weaviate Hybrid Search 스코어 결합 이해하기

Weaviate Hybrid Search는 크게 다음을 섞습니다.

  • BM25 점수(키워드 기반)
  • 벡터 유사도 점수(임베딩 기반)

일반적으로 alpha는 “벡터 쪽 비중”으로 이해하면 편합니다.

  • alpha = 0 이면 BM25만
  • alpha = 1 이면 벡터만
  • 01 사이면 혼합

다만 실제 품질 튜닝에서는 단순히 “벡터를 더 믿을까, 키워드를 더 믿을까”가 아니라, 쿼리 타입별로 최적 알파가 다르다는 점이 핵심입니다.

예를 들어:

  • 에러코드/함수명/옵션명 중심 쿼리: alpha를 낮춰 BM25 비중을 올림
  • 자연어 설명형 쿼리: alpha를 높여 벡터 비중을 올림

튜닝의 목표를 먼저 정의하기: RAG 관점 KPI

가중치 튜닝은 감으로 하면 끝이 없습니다. 최소한 아래 지표 중 하나를 목표로 잡는 게 좋습니다.

  1. Recall@K: 정답 문서(또는 정답 포함 청크)가 topK 안에 들어오는 비율
  2. MRR: 정답이 상위에 얼마나 빨리 등장하는지
  3. nDCG: 관련도 등급(강/중/약 관련)을 반영한 순위 품질
  4. Answer accuracy(LLM 평가): 검색 결과를 넣고 실제 답이 맞는지

실전에서는 보통

  • 1차로 Recall@K를 올려 “정답이 들어오게” 만들고
  • 2차로 MRR/nDCG를 올려 “정답이 위로 오게” 만들며
  • 마지막에 LLM 평가로 “정말 답이 좋아졌는지” 확인합니다.

실전 튜닝 루프: 쿼리 세그먼트별 알파 찾기

1) 쿼리를 세그먼트로 나누기

모든 쿼리에 단일 alpha를 적용하면, 어떤 쿼리에서는 좋아지고 어떤 쿼리에서는 나빠집니다. 그래서 먼저 쿼리를 분류합니다.

추천 세그먼트 예시:

  • Exact-token형: 코드/버전/옵션/에러 문자열 포함(예: 401, OOM, satisfies, BuildKit)
  • Entity형: 제품/서비스명 중심(예: Weaviate, Karpenter, Next.js)
  • Natural QA형: “왜 ~인가요”, “어떻게 ~하나요”
  • Long query형: 20단어 이상, 조건이 많음

분류는 정교한 모델이 아니라도, 정규식/룰 기반으로도 효과가 큽니다.

2) 알파 후보를 그리드로 평가

각 세그먼트에 대해 alpha0.0, 0.2, 0.4, 0.6, 0.8, 1.0처럼 스윕하고, Recall@K/MRR을 봅니다.

핵심은 “전체 평균”이 아니라 세그먼트별 최적을 찾는 것입니다.

3) 운영에서는 동적 알파(쿼리 기반)로 적용

  • Exact-token형: alpha = 0.1 같은 낮은 값
  • Natural QA형: alpha = 0.7 같은 높은 값
  • 애매하면 중간값 0.5

이 접근은 단일 알파보다 안정적으로 품질을 올립니다.

Weaviate GraphQL 예시: Hybrid + alpha + explain 로그

아래는 Weaviate에서 하이브리드 검색을 호출하는 전형적인 GraphQL 예시입니다. (필드명은 스키마에 맞게 조정하세요.)

{
  Get {
    Document(
      hybrid: {
        query: "weaviate hybrid search bm25 vector weight"
        alpha: 0.6
        properties: ["title", "content"]
      }
      limit: 10
    ) {
      title
      chunkId
      _additional {
        id
        score
        explainScore
      }
    }
  }
}
  • alpha로 BM25·벡터 비중을 조절합니다.
  • _additional { explainScore }는 “왜 이 결과가 나왔는지”를 튜닝/디버깅에 매우 유용하게 만듭니다.

explainScore를 수집해두면, 품질 이슈가 생겼을 때

  • BM25 쪽이 지배했는지
  • 벡터 유사도가 너무 약한데도 올라왔는지
  • 특정 토큰이 BM25를 과도하게 끌어올렸는지

를 빠르게 파악할 수 있습니다.

Python 예시: 쿼리 타입에 따른 동적 알파

아래는 쿼리 문자열에 “정확 토큰 신호”가 있으면 alpha를 낮추는 간단한 예시입니다.

import re
import requests

WEAVIATE_URL = "http://localhost:8080/v1/graphql"

TOKEN_HEAVY = re.compile(r"\b(\d{3}|HTTP|OOM|TS\s*5\.|v\d+|BuildKit|EKS|401|429)\b", re.I)


def choose_alpha(query: str) -> float:
    if TOKEN_HEAVY.search(query):
        return 0.2
    if len(query.split()) >= 18:
        return 0.7
    return 0.5


def hybrid_search(query: str, limit: int = 10):
    alpha = choose_alpha(query)

    gql = {
        "query": """
        query Hybrid($q: String!, $a: Float!, $limit: Int!) {
          Get {
            Document(
              hybrid: { query: $q, alpha: $a, properties: [\"title\", \"content\"] }
              limit: $limit
            ) {
              title
              chunkId
              _additional { id score explainScore }
            }
          }
        }
        """,
        "variables": {"q": query, "a": alpha, "limit": limit},
    }

    r = requests.post(WEAVIATE_URL, json=gql, timeout=30)
    r.raise_for_status()
    data = r.json()["data"]["Get"]["Document"]
    return alpha, data


if __name__ == "__main__":
    q = "Responses API 401인데 키가 맞는데도 왜 실패하죠"
    alpha, hits = hybrid_search(q)
    print("alpha=", alpha)
    for h in hits[:3]:
        print(h["title"], h["_additional"]["score"])

이 코드는 단순하지만, “알파를 고정값으로 박아두고 운에 맡기는” 상태보다 운영 안정성이 훨씬 좋아집니다.

BM25 쪽 품질을 올리는 전처리 포인트

알파를 조정해도 BM25 자체가 약하면 키워드 쿼리에서 계속 흔들립니다. 아래는 BM25가 잘 작동하도록 만드는 실전 포인트입니다.

1) 토큰 보존: 코드/에러/버전 문자열을 깨지 않기

수집/정제 단계에서

  • 백틱 코드 블록 제거
  • 특수문자 제거
  • 소문자화/정규화

를 과하게 하면, 정작 중요한 토큰이 사라집니다. 예를 들어 TS 5.6ts 56처럼 변형되면 BM25 히트가 약해집니다.

2) 필드 분리: title, headings, body를 섞지 말기

BM25는 필드별로 신호가 다릅니다.

  • title 매칭은 강한 신호
  • content는 노이즈가 섞일 수 있음

가능하면 propertiestitle, content를 모두 넣되, 운영에서 “title 매칭이 있는 결과를 우선”하는 후처리도 고려할 수 있습니다.

3) 청크에 키워드를 남기기

문서가 길어 청크로 나눌 때, 섹션 제목/상위 헤딩이 청크에 포함되지 않으면 BM25가 약해집니다.

권장:

  • 각 청크 앞에 해당 섹션의 헤딩을 프리픽스로 붙이기
  • title을 청크 메타데이터로 넣고 검색 대상 프로퍼티에 포함

벡터 쪽 품질을 올리는 포인트

1) 임베딩 모델과 도메인 적합성

기술 문서/로그/에러 중심 도메인은 일반 문장보다

  • 약어
  • 코드 토큰
  • 설정 키

가 많습니다. 이런 텍스트에 강한 임베딩을 선택하거나, 최소한 “코드 블록을 통째로 제거” 같은 전처리를 피해야 합니다.

2) 청크 길이와 overlap

  • 너무 길면 주제가 섞여 벡터가 흐려짐
  • 너무 짧으면 문맥이 부족

실무에서 자주 쓰는 출발점:

  • 300~800 토큰 범위
  • 10~20% overlap

그리고 “정답이 있는 섹션”이 잘 분리되는지 실제 쿼리로 확인합니다.

topK, rerank, 컨텍스트 구성까지 함께 봐야 한다

하이브리드 튜닝은 alpha만 만지면 끝나지 않습니다.

1) topK는 Recall과 비용의 교환

  • topK를 올리면 정답이 들어올 확률은 올라가지만
  • LLM 컨텍스트가 길어져 비용/지연이 증가하고
  • 노이즈가 늘어 답이 나빠질 수도 있습니다.

권장 접근:

  • 리트리벌 topK는 20~50으로 넉넉히 가져오고
  • 그 다음 단계에서 rerank로 5~10개로 줄여 컨텍스트를 구성

2) 하이브리드 + rerank 조합

하이브리드로 후보를 넓게 모으고, reranker(크로스 인코더 등)로 재정렬하면

  • 알파 민감도가 낮아지고
  • 쿼리 세그먼트별 편차가 줄어
  • 운영 안정성이 좋아집니다.

품질 이슈를 “재현 가능”하게 만드는 로그 설계

RAG 튜닝이 어려운 이유는 같은 질문이라도

  • 인덱스가 바뀌고
  • 임베딩 모델이 바뀌고
  • 문서가 추가/삭제

되면서 결과가 계속 흔들리기 때문입니다.

최소한 아래를 로그로 남기면, 회귀(regression)를 잡기 쉬워집니다.

  • 쿼리 문자열
  • 적용된 alpha
  • 필터 조건(테넌트, 버전, 언어)
  • 검색 결과 topN의 id, score, explainScore
  • 최종 컨텍스트에 포함된 청크 목록

이런 “원인 추적 가능한 로그”는 인프라/빌드 문제를 좁혀갈 때와 동일한 가치가 있습니다. 예를 들어 캐시/빌드 재현성을 다루는 글인 Docker BuildKit 캐시가 안 먹을 때 진단·해결처럼, RAG도 결국은 재현 가능한 관측이 있어야 튜닝이 됩니다.

추천 튜닝 레시피(바로 적용 가능한 순서)

  1. 쿼리 50~200개를 모아 “정답 청크” 라벨링(최소한 정답 문서 id라도)
  2. alpha0.0부터 1.0까지 스윕해 Recall@10, MRR@10 측정
  3. 쿼리를 2~4개 세그먼트로 나누고, 세그먼트별 최적 alpha를 찾기
  4. 운영에 동적 알파 적용(룰 기반으로 시작)
  5. topK를 넉넉히, rerank로 줄이는 구조로 안정화
  6. explainScore와 컨텍스트 구성을 로그로 남기고 회귀 테스트 만들기

이 과정을 거치면 “BM25를 더 믿어야 하나요, 벡터를 더 믿어야 하나요” 같은 감각적 논쟁이 아니라, 측정 가능한 품질 개선으로 수렴합니다.

마무리: 알파는 ‘정답’이 아니라 ‘정책’이다

Weaviate Hybrid Search에서 alpha는 정답값이 아니라, 조직의 문서/쿼리 특성에 맞춘 검색 정책입니다. 특히 RAG에서는 검색이 곧 답의 상한선을 결정하므로, 알파 튜닝은 모델 프롬프트보다 ROI가 큰 경우가 많습니다.

  • 키워드가 중요한 쿼리는 BM25 비중을 올리고
  • 자연어 질의는 벡터 비중을 올리며
  • 운영에서는 세그먼트 기반 동적 알파 + rerank로 안정화

이 3가지만 지켜도, “가끔은 맞고 가끔은 틀리는” RAG에서 “대부분은 일관되게 맞는” RAG로 한 단계 올라갈 수 있습니다.

추가로 Next.js 기반 서비스에서 RAG 호출 결과가 캐시/중복 fetch로 흔들리는 문제까지 겹치면 관측이 더 어려워집니다. 프런트/서버 캐시 이슈를 함께 다루려면 Next.js 14 RSC 캐시 꼬임·중복 fetch 7가지도 같이 점검해보는 것을 권장합니다.