Published on

OpenSearch RAG 하이브리드검색 튜닝 - BM25+벡터

Authors

서론: RAG에서 하이브리드가 필요한 이유

RAG 리트리벌 품질은 대체로 두 가지 축에서 흔들립니다. 하나는 정확히 같은 키워드/고유명사/코드 토큰을 찾는 능력(정확 일치, 희소 신호)이고, 다른 하나는 의미적으로 비슷한 문장을 찾는 능력(밀집 신호)입니다. BM25는 전자에 강하고, 벡터 검색은 후자에 강합니다.

하지만 실무에서는 한쪽만 쓰면 다음 문제가 반복됩니다.

  • BM25만 사용: 동의어/패러프레이즈에 약해 “정답 문서가 있는데 못 찾는” 케이스가 생김
  • 벡터만 사용: 숫자/버전/에러코드/정확한 용어가 섞이면 엉뚱한 문서가 상위로 올라오거나, 근거가 흐릿해짐

그래서 OpenSearch에서 BM25 + 벡터를 결합한 하이브리드 검색을 튜닝해, RAG의 recallprecision을 동시에 끌어올리는 접근이 필요합니다. 이 글은 “그냥 섞기”가 아니라 스코어를 어떻게 결합하고, 어떤 파라미터를 어떤 순서로 조정하며, 무엇을 지표로 검증할지에 초점을 둡니다.

또한 리트리벌이 좋아져도 생성 단계에서 근거가 흐려지면 환각이 줄지 않습니다. 근거 검증 파이프라인까지 함께 보려면 RAG 환각 줄이기 - Citation 기반 검증 파이프라인도 같이 참고하는 것을 권합니다.

하이브리드 검색의 3가지 결합 패턴

OpenSearch에서 BM25와 벡터를 함께 쓰는 방식은 크게 3가지로 나눌 수 있습니다.

1) 후보군 2단계(리콜 먼저, 랭킹 나중)

  • 1단계: BM25 또는 벡터로 넓게 후보를 뽑음
  • 2단계: 다른 신호로 재정렬(혹은 둘을 합산)

장점: 운영/튜닝이 직관적이고 비용을 통제하기 쉽습니다. 단점: 2단계 재정렬을 위한 쿼리 작성이 다소 복잡해질 수 있습니다.

2) 단일 쿼리에서 점수 결합(스코어 퓨전)

  • BM25 점수와 벡터 유사도 점수를 한 번에 결합

장점: 쿼리 한 번으로 끝나 단순합니다. 단점: 점수 스케일이 다르기 때문에 정규화/가중치 튜닝이 필수입니다.

3) 결과 리스트 퓨전(RRF 등)

  • BM25 Top k 리스트와 벡터 Top k 리스트를 만든 뒤, 순위 기반으로 합칩니다.

장점: 점수 스케일 문제를 회피할 수 있어 튜닝 난이도가 낮습니다. 단점: 구현/파이프라인이 필요하고, k/상수 튜닝이 성능에 크게 영향을 줍니다.

이 글에서는 OpenSearch에서 실무적으로 가장 많이 쓰는 (1) 후보군 2단계(2) 단일 쿼리 결합을 중심으로 설명합니다.

인덱스 설계: BM25 필드와 벡터 필드를 분리하라

하이브리드 튜닝의 절반은 인덱스 설계에서 결정됩니다.

텍스트 필드(희소)

  • title, body, tags처럼 검색 의도가 반영되는 필드에 analyzer를 명확히 분리
  • 한국어는 형태소 분석기(예: Nori 계열)를 고려하되, 고유명사/버전/에러코드가 깨지지 않게 keyword 서브필드를 함께 둡니다.

벡터 필드(밀집)

  • 문서 단위로 1개 벡터를 둘지, 청크 단위로 여러 벡터를 둘지부터 결정해야 합니다.
  • RAG는 보통 청크 검색이므로, chunk_id, doc_id, section 같은 메타를 반드시 함께 저장해 후처리가 가능해야 합니다.

아래는 예시 매핑입니다. (플러그인/버전에 따라 knn_vector 설정은 다를 수 있으니, 사용 중인 OpenSearch 버전 문서를 확인하세요.)

PUT rag_chunks
{
  "settings": {
    "index": {
      "number_of_shards": 3,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "properties": {
      "doc_id": { "type": "keyword" },
      "chunk_id": { "type": "keyword" },
      "title": {
        "type": "text",
        "fields": {
          "raw": { "type": "keyword" }
        }
      },
      "body": {
        "type": "text",
        "fields": {
          "raw": { "type": "keyword" }
        }
      },
      "tags": { "type": "keyword" },
      "embedding": {
        "type": "knn_vector",
        "dimension": 768
      },
      "updated_at": { "type": "date" }
    }
  }
}

튜닝의 핵심: BM25 점수와 벡터 점수는 스케일이 다르다

BM25 점수는 쿼리 길이/문서 길이/희소도에 따라 분포가 크게 바뀌고, 벡터 유사도(코사인/내적)는 대체로 -1..1 또는 모델/정규화에 따라 다른 범위를 가집니다.

따라서 단순히 bm25_score + vector_score를 하면 한쪽이 항상 이기는 구조가 됩니다. 하이브리드 튜닝은 본질적으로 다음 문제를 푸는 것입니다.

  • BM25 신호가 강하게 작동해야 하는 쿼리(예: 에러코드, 함수명, 버전)는 BM25가 이기게
  • 의미 검색이 필요한 쿼리(예: “로그 비용 줄이는 방법”)는 벡터가 이기게
  • 애매한 경우는 둘이 비슷한 수준에서 경쟁하게

이 문제를 풀기 위한 실전 전략을 순서대로 소개합니다.

전략 1: 후보군을 넓게 뽑고 rescore로 재정렬

운영에서 가장 안전한 방법은 다음입니다.

  1. 1차는 BM25로 Top N을 뽑는다(또는 벡터로 뽑는다)
  2. rescore 단계에서 벡터 유사도를 반영해 재정렬한다

장점은 벡터 검색 비용을 Top N 후보에만 쓰는 것이라, 지연시간과 비용을 컨트롤하기 쉽습니다.

아래는 개념 예시입니다. (실제 벡터 스코어링 함수/문법은 OpenSearch 버전과 사용 기능에 따라 달라질 수 있습니다. 핵심은 query_weightrescore_query_weight로 1차/2차 비중을 분리하는 것입니다.)

POST rag_chunks/_search
{
  "size": 10,
  "query": {
    "multi_match": {
      "query": "deadlock_detected 40P01 원인",
      "fields": ["title^2", "body"]
    }
  },
  "rescore": {
    "window_size": 200,
    "query": {
      "rescore_query": {
        "script_score": {
          "query": { "match_all": {} },
          "script": {
            "source": "cosineSimilarity(params.q, 'embedding') + 1.0",
            "params": {
              "q": [0.01, 0.02, 0.03]
            }
          }
        }
      },
      "query_weight": 0.7,
      "rescore_query_weight": 0.3
    }
  }
}

window_size 튜닝 가이드

  • 너무 작으면 벡터가 재정렬할 후보가 부족해 recall이 떨어집니다.
  • 너무 크면 지연시간이 급증합니다.

실무에서는 다음 순서로 잡는 것이 안정적입니다.

  1. window_size를 100~300 사이에서 시작
  2. 오프라인 평가셋에서 recall@k가 안정적으로 올라오는 최소값을 찾기
  3. 프로덕션에서 p95 latency가 허용 범위를 넘지 않는지 확인

전략 2: 단일 쿼리에서 should로 BM25와 벡터를 결합

후보군 2단계가 번거롭거나, 쿼리 한 번으로 끝내고 싶다면 boolshould를 이용해 “둘 중 하나라도 맞으면 점수” 구조를 만들 수 있습니다.

문제는 앞서 말한 스케일 차이이므로, 아래 중 하나를 반드시 포함해야 합니다.

  • 벡터 점수를 0..1 또는 0..2처럼 제한된 범위로 이동/스케일링
  • BM25 점수에 boost를 걸어 밸런싱
  • 쿼리 유형에 따라 가중치를 동적으로 변경

예시:

POST rag_chunks/_search
{
  "size": 10,
  "query": {
    "bool": {
      "should": [
        {
          "multi_match": {
            "query": "EKS /dev/shm 부족 OOM",
            "fields": ["title^2", "body"],
            "boost": 1.2
          }
        },
        {
          "script_score": {
            "query": { "match_all": {} },
            "script": {
              "source": "(cosineSimilarity(params.q, 'embedding') + 1.0) * params.w",
              "params": {
                "q": [0.01, 0.02, 0.03],
                "w": 0.6
              }
            }
          }
        }
      ],
      "minimum_should_match": 1
    }
  }
}

쿼리 타입별 동적 가중치(추천)

RAG에서 “모든 쿼리에 동일 가중치”는 대개 성능이 나쁩니다. 간단한 휴리스틱만으로도 개선됩니다.

  • 숫자/버전/에러코드/슬래시가 포함된 쿼리: BM25 가중치 상승
  • 자연어 질문형(“방법”, “차이”, “원인”): 벡터 가중치 상승

예를 들어 다음처럼 분기합니다.

  • 정규식으로 40P01, OOM, v1.2.3, dev/shm 패턴 감지 시 bm25_boost=1.6, vector_w=0.4
  • 그 외는 bm25_boost=1.1, vector_w=0.8

이 방식은 학습 없이도 즉시 효과가 나고, 이후 학습 기반 랭커로 확장하기도 쉽습니다.

전략 3: 필드 부스팅과 BM25 파라미터로 “키워드 신뢰도”를 조정

BM25 튜닝은 크게 두 가지입니다.

  1. 필드 부스팅: title^2, tags^3처럼 신뢰도 높은 필드에 가중치
  2. 유사 토큰 노이즈 감소: analyzer/불용어/동의어 사전 관리

특히 RAG에서는 본문(body)이 길어질수록 BM25가 문서 길이 정규화의 영향을 많이 받습니다. 이때는 다음을 고려합니다.

  • 청크 크기를 너무 크게 잡지 않기(예: 200~400 토큰 수준에서 실험)
  • title이나 섹션 헤더를 청크 텍스트에 포함해 BM25 신호를 보강

또한 동의어 사전은 벡터와 충돌할 수 있습니다. 동의어 확장을 과하게 하면 BM25가 “그럴듯한데 근거는 다른” 문서를 끌어올려 RAG 근거 품질을 해칠 수 있으니, 동의어는 도메인 핵심 용어 위주로 제한하는 편이 안전합니다.

전략 4: 벡터 검색 파라미터는 recall과 지연시간의 교환

벡터 쪽 튜닝은 대개 다음 트레이드오프입니다.

  • ef_search 같은 탐색 폭을 키우면 recall이 증가하지만 지연시간도 증가
  • 인덱스 빌드 파라미터(m, ef_construction)를 높이면 품질이 오르지만 인덱싱 비용/메모리가 증가

실무 튜닝 순서는 보통 다음이 효율적입니다.

  1. 모델/임베딩 차원/정규화 여부를 먼저 고정
  2. 검색 시 파라미터(예: ef_search)를 조정해 recall@k를 목표치까지 끌어올림
  3. 그래도 부족하면 인덱스 파라미터를 조정하고 재색인

여기서 중요한 관점은 “벡터 recall이 충분히 높아야 하이브리드가 의미가 있다”는 점입니다. 벡터가 제대로 후보를 가져오지 못하면, 결국 BM25에만 의존하는 구조가 됩니다.

전략 5: RAG 관점의 평가 지표를 먼저 정의하라

하이브리드 튜닝은 감으로 하면 끝이 없습니다. 최소한 아래 지표를 잡고 실험해야 합니다.

오프라인 리트리벌 지표

  • recall@k: 정답 문서(또는 정답 청크)가 Top k 안에 들어오는 비율
  • mrr@k: 정답이 얼마나 위에 랭크되는지
  • nDCG@k: 다중 정답/가중 정답이 있는 경우 유용

온라인 지표

  • p50/p95 latency
  • “답변 채택률” 또는 “사용자 재질문율” 같은 제품 지표
  • RAG라면 “인용 근거 클릭률/근거 커버리지” 같은 관측치

특히 생성 모델이 강해질수록 “검색이 조금 틀려도 그럴듯하게 답하는” 현상이 생겨 리트리벌 품질 저하가 잘 숨습니다. 그래서 Citation 기반 검증을 붙여 “근거가 실제로 답을 지지하는지”를 측정하는 것이 효과적입니다. 자세한 파이프라인은 앞서 언급한 글(RAG 환각 줄이기 - Citation 기반 검증 파이프라인)을 참고하세요.

실전 튜닝 플로우(권장 순서)

아래 순서대로 하면 시행착오가 줄어듭니다.

1) 데이터/청크 품질부터 고정

  • 청크에 제목/섹션명 포함
  • 너무 긴 청크 금지(키워드/의미 모두 희석)
  • 중복 청크 제거(동일 문장이 여러 번 나오면 벡터가 쏠림)

2) BM25 단독 기준선 만들기

  • multi_match + 필드 부스팅
  • 키워드형 쿼리(에러코드/버전)에서 성능 확인

3) 벡터 단독 기준선 만들기

  • 벡터 검색 파라미터를 올려 recall@k 확보
  • 의미형 쿼리(설명/비교/요약)에서 성능 확인

4) 하이브리드 결합

  • 먼저 후보군 2단계(rescore)로 결합
  • 이후 단일 쿼리 결합은 운영 단순화가 필요할 때 시도

5) 동적 가중치 도입

  • 쿼리 패턴 기반 휴리스틱으로 시작
  • 로그가 쌓이면 학습 기반(예: 랭킹 모델)으로 확장

운영에서 자주 겪는 문제와 체크리스트

문제 1: 특정 용어가 포함되면 벡터가 망가진다

증상: “정확한 토큰”이 중요한데 의미 유사 문서가 올라옴

대응:

  • 쿼리 패턴 감지 시 BM25 가중치 상승
  • body.raw 같은 keyword 필드로 exact match를 should에 추가
{
  "term": {
    "body.raw": {
      "value": "40P01",
      "boost": 3.0
    }
  }
}

문제 2: 벡터가 항상 이겨서 키워드가 묻힌다

대응:

  • 벡터 점수에 스케일링 적용(예: * 0.3)
  • rescore로 벡터 영향 범위를 Top N으로 제한

문제 3: 지연시간이 튄다

대응:

  • rescore window_size를 줄이고, 대신 1차 후보를 더 잘 뽑도록 BM25 필드 부스팅 개선
  • 벡터 탐색 폭 파라미터를 단계적으로 조정하며 p95를 관찰

추가로, RAG 시스템은 검색뿐 아니라 임베딩 생성/서빙에서 메모리 병목이 자주 납니다. 로컬 LLM이나 임베딩 모델에서 OOM이 난다면 Transformers 로컬 LLM OOM? 8bit·4bit 로딩 가이드도 함께 보면 운영 안정화에 도움이 됩니다.

결론: 하이브리드는 “결합”보다 “캘리브레이션”이 성패를 가른다

OpenSearch에서 BM25와 벡터를 함께 쓰는 것은 어렵지 않지만, 성능을 만드는 건 결국 **점수 캘리브레이션(정규화/가중치/동적 분기)**과 평가 지표 기반 반복 실험입니다.

  • 빠르게 성능을 내고 싶다면 후보군 2단계 + rescore로 시작
  • 점수 스케일 문제를 회피하고 싶다면 결과 리스트 퓨전(RRF 계열)을 고려
  • 제품화 단계에서는 쿼리 타입별 동적 가중치와 근거 검증(Citation)을 붙여 “좋아 보이는 답”이 아니라 “근거 있는 답”으로 수렴시키는 것이 핵심입니다.

다음 단계로는 (1) 쿼리 로그 기반으로 하이브리드 가중치를 자동 학습하거나, (2) 도메인별 평가셋을 만들어 배포 전 리트리벌 회귀 테스트를 돌리는 체계를 추천합니다.