검색에는 두 갈래가 있다. BM25는 글자가 같은 문서를 찾고, 벡터 검색은 뜻이 가까운 문서를 찾는다. 둘은 서로 잘 찾는 것이 달라서 함께 쓰는 경우가 많고, 이를 하이브리드 검색이라고 한다. 두 결과는 점수 단위가 달라 그대로 더할 수 없으므로, 순위만 보고 합치는 RRF를 쓴다.

BM25

BM25는 검색어의 낱말이 문서에 얼마나 나오는지로 점수를 매기는 방식이다. 역색인을 이용하고, OpenSearch의 기본 점수 계산법이다.

  • 검색어 낱말이 문서에 많이 나올수록 점수가 오른다. 다만 계속 비례해서 오르지는 않고 점점 덜 오른다.
  • 여러 문서에 흔하게 나오는 낱말보다 드물게 나오는 낱말이 일치할 때 점수가 더 크다.
  • 긴 문서는 낱말이 우연히 많이 나오기 쉬우므로 길이만큼 점수를 깎는다.

낱말이 실제로 일치해야 잡힌다. 정확한 용어, 상품명, 에러 코드를 찾는 데 강하다. '환불'로 검색하면 '돈을 돌려받고 싶음'은 찾지 못한다.

BM25 점수는 16.1처럼 그 검색 안에서만 의미 있는 상대값이다. 검색어나 인덱스가 바뀌면 크기가 달라진다.

벡터 검색과 임베딩

벡터 검색은 문장을 숫자 배열(벡터)로 바꾸고, 검색어 벡터와 가까운 벡터를 가진 문서를 찾는다. 낱말이 달라도 뜻이 가까우면 찾는다.

헷갈리기 쉬운 세 가지를 나누면 이렇다.

  • 임베딩 모델: 문장을 벡터로 바꾸는 모델이다. 많은 텍스트로 학습해서, 뜻이 비슷한 문장을 가까운 위치에 놓도록 만들어져 있다.
  • 임베딩: 그 모델이 내놓은 벡터 자체다. 모델에 따라 수백에서 수천 개의 숫자다.
  • 색인: 그 벡터를 가까운 것끼리 빨리 찾을 수 있는 구조로 저장하는 일이다.
저장할 때: 문장 → [임베딩 모델] → 벡터 → [색인] → 벡터 인덱스
검색할 때: 검색어 → [임베딩 모델] → 벡터 → 벡터 인덱스에서 가까운 것 찾기

벡터로 바꾸는 일과 가까운 것을 고르는 일은 서로 다른 순간에 일어난다. 저장할 때와 검색할 때 같은 임베딩 모델을 써야 같은 공간의 벡터끼리 비교할 수 있다. 모델을 바꾸면 모든 문서를 다시 임베딩해야 한다.

임베딩 모델의 판단은 완벽하지 않다. 정확한 용어 일치는 오히려 BM25보다 약할 수 있어서 둘을 함께 쓴다.

OpenSearch의 벡터 인덱스

OpenSearch는 knn_vector 타입 필드에 벡터를 저장하고, knn 쿼리로 가까운 벡터를 찾는다. 가까운 벡터를 빨리 찾으려면 인덱스를 만들 때 index.knn을 켜야 한다. 이 설정이 있어야 HNSW 같은 근사 최근접 이웃(ANN, Approximate Nearest Neighbor) 탐색 구조를 만든다.

json
PUT /docs
{
  "settings": { "index": { "knn": true } },
  "mappings": {
    "properties": {
      "body": { "type": "text", "analyzer": "nori" },
      "v": { "type": "knn_vector", "dimension": 3, "space_type": "cosinesimil" }
    }
  }
}

OpenSearch 3.9.0에서 확인한 동작은 이렇다.

  • index.knn 없이 만든 인덱스에도 knn_vector 필드는 추가되고 벡터도 저장된다.
  • 그 필드로 knn 검색을 하면 Field 'v' is not built for ANN search.로 실패한다.
  • 이미 열린 인덱스에 index.knn을 켜려 하면 Can't update non dynamic settings [[index.knn]] for open indices로 실패한다.
  • 처음부터 index.knn을 켠 인덱스는 검색된다. 벡터 [1, 0, 0]으로 찾으면 같은 벡터가 1.0, [0.9, 0.1, 0]이 0.997, [0, 0, 1]이 0.5로 나온다.

벡터를 저장하는 것과 가까운 벡터를 빨리 찾는 것은 다른 일이다. 이미 운영 중인 인덱스에 벡터 검색을 붙이려면 index.knn을 켠 새 인덱스를 만들고 재색인해야 한다. 재색인은 문서를 옮기는 것만이 아니라 문서마다 임베딩을 새로 만들어야 하므로, OpenSearch의 _reindex가 아니라 애플리케이션이 DB 원본을 읽어 임베딩과 함께 넣는다.

dimension과 개수가 다른 벡터는 저장을 거절한다. 벡터 3,072개를 쓰는 모델이라면 문서 하나에 숫자 3,072개가 들어가므로, 같은 문서 수라도 BM25만 쓰던 인덱스보다 크기가 훨씬 커진다.

RRF로 두 결과 합치기

BM25 점수는 상대값이고, 코사인 유사도 기반 벡터 점수는 0-1 범위다. 단위가 달라 그대로 더하면 한쪽이 결과를 좌우한다. RRF(Reciprocal Rank Fusion)는 원래 점수를 버리고 순위만 쓴다.

RRF(d)=∑목록1k+순위(d)\text{RRF}(d) = \sum_{\text{목록}} \frac{1}{k + \text{순위}(d)}

목록마다 문서의 순위로 1 / (k + 순위)를 계산하고, 그 목록에 없으면 0으로 친 뒤 모두 더한다. k는 상수이고 OpenSearch 기본값은 60이다. 2009년 RRF 논문이 실험으로 정한 값이다.

예시

문서 네 개를 넣고 '환불'로 검색했다. 벡터는 설명을 위해 손으로 정한 3차원 값이다.

문서내용벡터
A환불 요청이 늦게 처리됨[1, 0, 0]
B환불 문의[0, 0, 1]
C돈을 돌려받고 싶음[0.95, 0.05, 0]
D배송 지연 안내[0, 1, 0]

OpenSearch 3.9.0에서 실행한 결과다.

검색결과(순위대로)
BM25 '환불'B(0.365), A(0.277)
벡터 [1, 0, 0], k=2A(1.0), C(0.999)
RRF 합침A(0.03252), B(0.01639), C(0.01613)
  • A: BM25 2등(1/62)과 벡터 1등(1/61)을 더해 0.03252로 1등이다.
  • B: BM25 1등(1/61)만 있어 0.01639다.
  • C: '환불'이라는 낱말이 없어 BM25에는 없고, 벡터 2등(1/62)만 있어 0.01613이다.

C는 BM25만으로는 찾을 수 없는 문서다. 하이브리드 검색은 이런 문서를 결과에 넣고, 양쪽 모두에서 검색된 A를 맨 위로 올린다.

k=60이 만드는 규칙

목록마다 상위 몇 개만 가져온다면, k = 60일 때 순위 차이가 점수에 거의 반영되지 않는다. 양쪽에서 3등씩 한 문서(1/63 + 1/63 = 0.0317)가 한쪽에서만 1등인 문서(1/61 = 0.0164)보다 위다. 결과적으로 '양쪽 모두에서 검색된 문서가 먼저, 그다음 순위대로'라는 규칙이 된다.

RRF가 버리는 것도 있다. 원래 점수의 크기다. BM25 1등이 2등보다 압도적으로 관련이 커도, 순위로는 한 칸 차이로만 취급된다.

OpenSearch에서 쓰기

RRF는 검색 파이프라인(search pipeline)으로 등록하고, hybrid 쿼리에 두 검색을 함께 넣는다.

json
PUT /_search/pipeline/rrf-pipeline
{
  "phase_results_processors": [
    { "score-ranker-processor": { "combination": { "technique": "rrf", "rank_constant": 60 } } }
  ]
}
 
GET /docs/_search?search_pipeline=rrf-pipeline
{
  "query": {
    "hybrid": {
      "queries": [
        { "match": { "body": "환불" } },
        { "knn": { "v": { "vector": [1, 0, 0], "k": 2 } } }
      ]
    }
  }
}
  • 파이프라인은 인덱스와 따로 저장된다. 인덱스가 이미 있으면 생성을 건너뛰는 코드 안에 파이프라인 등록을 넣으면, 기존 환경에서는 등록되지 않는다. 같은 이름으로 다시 등록하면 덮어쓰므로 시작할 때마다 등록해도 된다.
  • 사용자별 필터처럼 결과를 좁히는 조건은 hybrid 안의 두 쿼리에 각각 걸어야 한다. 한쪽에만 걸면 다른 쪽 목록으로 다른 사용자의 문서가 섞여 들어온다.
  • 임베딩 API 호출이 실패하면 벡터 쪽을 뺀 BM25만으로 검색하도록 대체 경로를 두면, 외부 API 장애가 검색 전체 장애로 번지지 않는다.

점수를 0-1로 맞춘 뒤 더하는 정규화(min-max) 방식도 있다. 이 방식은 13.9와 13.7처럼 작은 차이가 0.08과 0처럼 크게 벌어지고, 각 목록의 꼴찌가 늘 0 근처가 되는 문제가 있다.

공식 문서: OpenSearch, Reciprocal rank fusion, k-NN index