AWS 기술 블로그

한국어 파인튜닝된 SPLADE 기반 Neural Sparse 모델과 Amazon OpenSearch 하이브리드 검색 벤치마크

한국어 SPLADE v3 스타일 모델(sewoong/korean-neural-sparse-encoder-base-klue-large)을 Amazon OpenSearch Service에 배포하고, BM25 / Titan Embedding V2 / 각 하이브리드 조합을 MIRACL-ko로 비교했습니다. 코드와 정량 지표 중심으로 Sparse / Dense / Lexical을 어떻게 선택할지 판단할 수 있도록 정리했습니다.

검색의 출발점: TF-IDF와 BM25의 한계

정보 검색(Information Retrieval)은 사용자의 쿼리에 가장 관련성이 높은 문서를 찾아내는 문제입니다. 이 문제에 쓰이는 고전적인 통계 기반 가중치 기법이 TF-IDF(Spärck Jones, 1972)이고, 1990년대 초 Robertson, Walker 등이 확률론적 관련성 프레임워크(Probabilistic Relevance Framework)에서 발전시킨 BM25는 20년 이상 대규모 상용 검색 시스템의 사실상 표준으로 사용되어 왔습니다. BM25는 쿼리 토큰의 문서 내 빈도(TF), 전체 코퍼스에서의 희귀도(IDF), 문서 길이 정규화를 결합하여 점수를 계산하며, 인덱싱과 쿼리 모두 매우 빠르고 메모리 효율적입니다.

그러나 BM25는 본질적으로 어휘 일치(lexical matching)에만 의존한다는 한계가 있습니다. 쿼리에 “자동차”가 들어있고 문서에는 “차량”만 등장하면 두 표현이 의미적으로 동일해도 BM25는 관련성을 인식하지 못합니다. 이를 어휘 불일치 문제(vocabulary mismatch problem)라고 하며, 동의어·축약어·다의어·오탈자 등이 만연한 실제 검색 환경에서 검색 정확도 저하의 주된 원인이 됩니다. 또한 쿼리가 짧을수록 BM25의 신호는 약해지고, 긴 문서는 길이 정규화에도 불구하고 과소평가되는 경향이 있습니다.

Dense Vector의 등장과 그 한계

Dense Vector 기반 검색 아이디어는 DSSM(Huang et al., 2013)이나 DPR(Karpukhin et al., 2020) 같은 초기 연구에서 제시되었고, BERT 이후의 사전학습 언어 모델(Pretrained Language Model) 발전과 함께 본격적으로 상용화되었습니다. 텍스트를 고정 차원의 실수 벡터(예: 768차원, 1024차원)로 임베딩한 뒤 벡터 간 cosine 또는 inner product 유사도로 검색하는 방식으로, “자동차 ≈ 차량” 같은 의미적 유사성을 자연스럽게 포착하고 HNSW 같은 ANN(Approximate Nearest Neighbor) 인덱스로 대규모에서도 실용적 속도를 달성합니다. Amazon Bedrock 의 Titan Embeddings V2는 상용화된 예시 중 하나입니다.

그러나 Dense Vector에도 명확한 한계가 존재합니다. 첫째, 정확한 키워드 매칭에 약합니다. 제품 번호, 인명, 약어, 오탈자 등 “문자 그대로”의 매칭이 중요한 쿼리에서 Dense는 의미적 근접성에 휩쓸려 정답을 놓치는 경우가 있습니다. 둘째, 해석 가능성이 없습니다. 왜 특정 문서가 상위에 올라왔는지 차원별로 설명할 수 없어 디버깅과 품질 개선이 어렵습니다. 셋째, 도메인 전이(transfer)가 약합니다. 일반 코퍼스로 학습된 임베딩은 도메인 특수 어휘(의료, 법률, 전자상거래 등)에서 품질이 크게 저하되며 별도의 파인튜닝(fine-tuning) 비용이 발생합니다. 넷째, 학습 분포 밖(out-of-distribution)의 쿼리에 취약합니다. 학습 시 접하지 못한 주제나 문체가 들어오면 임베딩 품질이 급격히 떨어지는 반면, BM25 같은 어휘(lexical) 기반 방식은 이러한 분포 변화에 상대적으로 덜 민감합니다.

Neural Sparse Retrieval의 발전 경로

이 두 진영의 장점을 결합하려는 시도가 Neural Sparse Retrieval이고, BM25의 어휘 기반 검색 인프라를 그대로 활용하면서 신경망의 의미 이해 능력을 주입하는 것이 목표입니다. 핵심 아이디어는 어휘 확장(lexical expansion) 입니다. 문서를 인덱싱할 때 실제 등장한 토큰뿐 아니라 “만약 이 문서를 검색한다면 어떤 쿼리 토큰이 쓰일 것인가”를 예측해 가중치를 부여한 토큰 집합을 만들어 inverted index에 저장하는 것입니다. 다음은 이 계보의 주요 이정표입니다.

  • Doc2query (Nogueira et al., 2019) — 문서를 T5와 같은 seq2seq 모델에 넣어 “이 문서를 타겟으로 할 법한 쿼리”를 여러 개 생성하고, 생성된 쿼리 텍스트를 원 문서에 이어 붙여 BM25 인덱스에 함께 넣습니다. 어휘 확장을 “텍스트 생성”으로 접근한 첫 실용 사례이며 BM25의 recall을 크게 끌어올렸습니다.
  • DeepCT (Dai & Callan, 2019) — 각 토큰의 문맥 기반 중요도(contextualized term weight)를 BERT로 예측하여 BM25의 TF 값을 대체합니다. “the”처럼 흔한 단어의 가중치는 낮추고 핵심 키워드의 가중치는 올리는 방식으로 기존 inverted index 구조를 유지하면서 랭킹 품질을 개선했습니다.
  • SparTerm (Bai et al., 2020) — MLM의 어휘 예측 능력을 검색에 직접 접목한 초기 연구입니다. 문서의 각 위치에서 MLM이 예측하는 어휘 분포를 합산하여 문서 전체의 sparse 표현을 만들되, 기존 토큰은 그대로 유지하는 gating을 함께 학습합니다. Doc2query가 “텍스트 생성으로 확장”했다면 SparTerm은 “MLM 로짓으로 확장”하는 접근으로, 이후 SPLADE로 이어지는 직접적 전조가 됩니다.
  • SPLADE (Formal et al., 2021, 2022) — SparTerm을 발전시켜 log(1 + ReLU) 활성화 + max pooling + FLOPS 정규화를 결합한 end-to-end 학습 가능한 아키텍처를 제시했습니다. FLOPS 손실로 sparsity를 학습 과정에 내재화하여 posting list 길이와 latency를 명시적으로 제어할 수 있게 되었고, Dense Vector 수준의 retrieval 품질을 inverted index로 달성했습니다. 후속 SPLADE v3(Lassance et al., 2024)는 KL-divergence distillation, hard negative mining, listwise/pair-wise loss 혼합으로 품질을 더 높였습니다.

이 글은 실제 상용에서 검증된 SPLADE v3 스타일 한국어 모델(sewoong/korean-neural-sparse-encoder-base-klue-large)을 Amazon OpenSearch Service에 배포하고, BM25와 Amazon Bedrock Titan Embedding V2 Dense 모델 및 각 하이브리드 조합과 MIRACL-ko 벤치마크로 비교한 결과를 다룹니다. 인덱싱부터 쿼리, 메트릭 계산까지 코드 중심으로 설명합니다. 이를 통하여 Amazon OpenSearch Service에서 검색 서비스를 구축하고 Neural Sparse Retrieval을 도입하기 위하여 Dense/Sparse/Lexical 방식으로 정량적 비교할 수 있습니다.

Neural Sparse Model이란?

Dense vs Sparse 의 컨텍스트 표현 방식의 차이

검색 시스템에서 텍스트를 벡터로 표현하는 방식은 크게 두 가지입니다.

  • Dense Vector는 책 한 권의 내용을 향수로 추출한 것에 비유할 수 있습니다. 1,024가지 화학 성분 비율(차원)로 책의 “느낌”이 압축되어 있어 두 책의 향이 비슷하면 의미도 비슷하다고 판단합니다. 단, 어떤 성분이 무엇을 뜻하는지는 알 수 없고, “아마존웹서비스”라는 정확한 단어가 있는지를 묻는 식의 문자 단위 매칭은 불가능합니다.
  • Sparse Vector는 책의 모든 단어 위에 형광펜으로 중요도를 표시한 것에 비유할 수 있습니다. 32,000개 단어(차원) 중 실제로 칠해진 것은 40~80개뿐이고, 형광펜의 진하기(가중치)가 곧 단어의 중요도입니다. “이 책에 어떤 단어가 얼마나 중요하게 표시되어 있는가”가 그대로 보이므로 해석 가능하고, BM25와 같은 단어 단위 매칭의 정확성도 그대로 살아 있습니다.

같은 입력 “서울에서 저녁 먹기 좋은 곳”을 두 방식으로 표현하면 다음과 같은 차이가 있습니다.

[Dense, 1024차원, 모든 차원에 값 존재]
[0.023, -0.041, 0.118, 0.005, -0.072, ..., 0.031]
  → 각 차원이 무엇인지 알 수 없음 (블랙박스)

[Sparse, 32,000차원 중 ~40개만 비영]
{"서울": 2.31, "저녁": 2.04, "맛집": 1.98, "음식점": 1.71, "레스토랑": 1.54,
 "다이닝": 1.23, "추천": 1.18, "데이트": 1.03, ...}
  → 각 차원이 한국어 단어와 대응 (해석 가능)

두 방식의 핵심 차이를 정리하면 다음과 같습니다.

항목 Dense Vector Sparse Vector
차원 수 작음 (768, 1024 등) 큼 (= vocabulary 크기, 32K~)
비영 차원 수 모든 차원 (예: 1024개) 소수 (예: 40~80개)
각 차원의 의미 해석 불가 (블랙박스) 토큰(단어) 1:1 대응
유사도 계산 cosine / inner product dot product (sparse)
인덱스 자료구조 ANN (HNSW, IVF, FAISS) Inverted Index (Lucene 기반)
정확한 키워드 매칭 약함 강함 (BM25 수준)
의미적 매칭 강함 강함 (어휘 확장으로)
인프라 호환성 별도 ANN 엔진 필요 기존 BM25 인프라 재활용
메모리/디스크 효율 차원 수 × 4바이트(float32) 고정 비영 토큰만 저장 → 가변, 일반적으로 작음
디버깅 난이도 어려움 (왜 hit인지 설명 불가) 쉬움 (활성 토큰 보면 보임)

요약하면, Dense는 의미를 압축한 블랙박스 벡터, Sparse는 단어 단위 가중치를 가진 해석 가능한 표현입니다. SPLADE는 sparse 표현을 유지하면서 BM25가 못 하는 의미 확장을 더하는 절충점입니다.

SPLADE는 이상적인 점수 계산 방식으로 벡터 내적(dot product)을 사용합니다. 동작 원리는 단순합니다. 두 sparse 벡터 사이에 공통으로 존재하는 토큰의 가중치 곱을 합산하면 됩니다.

쿼리:  {"서울": 2.31, "맛집": 1.98, "음식점": 1.71}
문서:  {"서울": 1.50, "맛집": 2.20, "강남": 0.80, "데이트": 0.60}

공통 토큰: "서울", "맛집"
score = 2.31 × 1.50  +  1.98 × 2.20  =  3.465 + 4.356  =  7.821

다만 OpenSearch에서 sparse 검색을 구현하는 방법이 여러 가지이며 각각의 점수 함수가 다릅니다. 아래의 방식은 Inverted Index 계열로 동작하는 방식이며 rank_features 필드를 기반으로 사용 가능합니다.

구현방식 Score Function 결과
rank_feature query (기본) saturation: boost × v / (v + pivot) dot product 근사
rank_feature query + linear: {} linear: boost × v true dot product
neural_sparse query 내부적으로 linear sum true dot product
script_score + 사용자 정의 함수 임의 정의 가능 정확하지만 매우 느림

이번 벤치마크는 OpenSearch의 rank_feature query를 boost만 지정하고 호출하므로 기본값인 saturation 함수를 사용합니다. 이는 BM25의 TF saturation과 유사한 형태로, 단일 토큰의 doc weight가 너무 커지는 것을 자동으로 억제합니다. 순수 dot product를 원하면 linear: {}를 명시하거나 OpenSearch 2.11 이상 버전의 클러스터에서 neural_sparse 쿼리를 사용해야 합니다.

SPLADE의 작동 원리

SPLADE는 사전 학습된 Masked Language Model(MLM)의 어휘 예측 능력을 활용해 Sparse Vector를 생성합니다. BM25가 문서에 실제 등장하는 단어만 사용하는 것과 달리, SPLADE는 MLM의 언어 이해 능력을 활용해 문서에 직접 등장하지 않는 관련 단어까지 활성화합니다. 이를 어휘 확장(lexical expansion)이라 합니다. SPLADE 인코딩은 세 단계로 이루어집니다.

1단계: MLM 로짓 계산

입력 토큰 시퀀스를 MLM에 통과시켜 각 위치에서 전체 어휘에 대한 로짓을 얻습니다.

inputs = tokenizer(text, return_tensors="pt", max_length=256, truncation=True)
logits = model(**inputs).logits  # shape: [batch, seq_len, vocab_size]

2단계: log(1 + ReLU) 활성화

로짓에 ReLU를 적용해 음수를 제거한 후 log(1+x) 변환을 적용합니다. 이는 SPLADE v2 논문에서 제안된 핵심 활성화 함수로, 큰 값의 영향을 억제하면서 양수 값을 보존합니다.

sparse_scores = torch.log1p(torch.relu(logits)) # shape: [batch, seq_len, vocab_size]

3단계: Max Pooling

시퀀스 차원에 대해 max pooling을 수행해 각 어휘 토큰의 최대 활성화 값을 취합니다. 입력 길이와 무관하게 어휘 크기의 Sparse Vector가 생성됩니다.

mask = attention_mask.unsqueeze(-1).float() 
sparse_vec = (sparse_scores * mask).max(dim=1).values # shape: [batch, vocab_size]

어휘 확장 예시

위 세 단계를 거쳐 실제로 어떤 토큰들이 활성화되는지 한국어 예시로 살펴봅니다. 입력 쿼리 “서울에서 저녁 먹기 좋은 곳”을 SPLADE 인코더에 통과시키면 아래와 같이 쿼리에 직접 등장하지 않은 단어까지 의미적으로 연관된 토큰들이 가중치와 함께 활성화됩니다.

입력 쿼리: "서울에서 저녁 먹기 좋은 곳"

원본 토큰 (문자 그대로 등장):
  서울(2.31), 저녁(2.04), 먹기(1.87), 좋은(1.42), 곳(1.15)

확장 토큰 (MLM 어휘 확장으로 추가된 관련어):
  맛집(1.98), 음식점(1.71), 레스토랑(1.54), 다이닝(1.23),
  추천(1.18), 데이트(1.03), 강남(0.89), 홍대(0.82),
  한식(0.76), 분위기(0.71), 코스(0.64), ...

쿼리에 직접 등장하지 않은 “맛집”, “레스토랑”, “추천” 등이 MLM의 어휘 예측 능력을 통해 자동으로 확장되어 있습니다. 인덱싱 시에도 동일한 방식으로 문서가 확장되므로, BM25라면 놓쳤을 “서울 맛집 베스트 10” 같은 제목의 문서도 SPLADE는 “서울 저녁 먹기 좋은 곳” 쿼리로 찾아낼 수 있게 됩니다. 이것이 SPLADE가 BM25의 어휘 일치 기반 인프라(inverted index, dot product)를 그대로 사용하면서도 의미 검색 능력을 얻는 핵심 메커니즘입니다.

SPLADE v3의 개선점

SPLADE v3 논문(Lassance et al., 2024)은 기존 v2 대비 다음을 개선했습니다.

  • KL Divergence Distillation: Cross-encoder 교사 모델로부터 KL-div로 지식 증류하여 retrieval 품질 향상
  • Pair-wise와 Listwise Loss 혼합: InfoNCE(listwise) + MarginMSE(pair-wise)를 동시에 사용하여 학습 안정성 개선
  • FLOPS Regularization: 희소성(sparsity) 제약을 학습 과정에 내재화하여 index 크기 및 latency 제어
  • Hard Negative Mining: 대규모 후보에서 어려운 부정 예시를 채굴하여 경계 식별력 강화

이 글에서 비교하는 한국어 Sparse 모델 sewoong/korean-neural-sparse-encoder-base-klue-large는 이 중 FLOPS Regularization, Hard Negative Mining, InfoNCE listwise loss를 채택했습니다. MarginMSE는 한국어 sparse 파인튜닝 실험에서 품질을 오히려 저하시켜 제외했습니다. 이 모델은 KLUE 팀이 공개한 한국어 사전학습 MLM인 klue/roberta-large(337M params, 32K vocab)를 백본으로 파인튜닝한 모델입니다.

데이터셋: MIRACL-ko

MIRACL 소개

MIRACL(Multilingual Information Retrieval Across a Continuum of Languages)은 18개 언어를 아우르는 다국어 검색 데이터셋입니다. BEIR, MTEB 리더보드에서 다국어 retrieval 평가의 표준으로 사용됩니다. 한국어 split은 한국어 Wikipedia 기반이며 원어민이 직접 쿼리와 관련 passage를 라벨링했습니다.

통계

본 벤치마크에서는 dev split 전체를 사용합니다.

항목
쿼리 수 213
총 qrel pairs 547
쿼리당 정답 수 (평균) 2.57개
쿼리당 정답 수 (최대) 12개
코퍼스 크기 (subset) 10,000
Multi-positive 쿼리 비율 52.1% (111/213)

데이터 로딩 코드

HuggingFace datasets 라이브러리로 쿼리, qrels, 코퍼스를 로드합니다. 원본 MIRACL 코퍼스는 약 1.5M 문서로 너무 크므로, 정답·hard negative 전부(2,835건)를 포함하고 나머지 7,165건은 전체 코퍼스에서 seed=42로 랜덤 샘플링한 subset을 사용합니다.

from datasets import load_dataset
import random

dev_ds = load_dataset("miracl/miracl", "ko", split="dev", trust_remote_code=True)

query_relevant_docs = {}
passage_docs = {}

for row in dev_ds:
    qid = str(row["query_id"])
    positive_passages = row.get("positive_passages", []) or []
    negative_passages = row.get("negative_passages", []) or []

    if positive_passages:
        query_relevant_docs[qid] = [p["docid"] for p in positive_passages]

    for p in positive_passages + negative_passages:
        doc_id = p["docid"]
        title = p.get("title", "") or ""
        text = p.get("text", "") or ""
        passage_docs[doc_id] = (title + "
" + text).strip() if title else text

corpus_ds = load_dataset("miracl/miracl-corpus", "ko", split="train", trust_remote_code=True)
random.seed(42)
indices = list(range(len(corpus_ds)))
random.shuffle(indices)

documents = dict(passage_docs)
for idx in indices:
    if len(documents) >= 10000:
        break
    doc = corpus_ds[idx]
    if doc["docid"] not in documents:
        documents[doc["docid"]] = (doc["title"] + "
" + doc["text"]).strip()

정답셋 예시

Q4: “룩셈부르크의 수도는 어디인가?”

정답 doc_id 내용 (요약)
3462#0 룩셈부르크 대공국의 수도는 룩셈부르크이다
3462#2 룩셈부르크 수도는 룩셈부르크이다
585718#1 에슈쉬르알제트… 룩셈부르크에서 두 번째로 큰 도시이다

doc_id 형식은 {wikipedia_page_id}#{passage_idx}입니다.

사용 모델

BM25 (Lexical)

OpenSearch에 내장된 BM25 스코어링과 nori_tokenizer를 조합하여 한국어 형태소 기반 검색을 수행합니다. 파라미터는 OpenSearch 기본값(k1=1.2, b=0.75)을 사용합니다.

Amazon Bedrock Titan Embedding V2 (Dense)

  • 모델 ID: amazon.titan-embed-text-v2:0
  • 차원: 1024

한국어 Neural Sparse Model (SPLADE)

  • HuggingFace: sewoong/korean-neural-sparse-encoder-base-klue-large
  • 백본: klue/roberta-large (337M params)
  • 어휘 크기: 32,000 (한국어 전용 토크나이저)
  • 학습 방식: SPLADE v3 스타일 (FLOPS reg + InfoNCE + hard negatives)
  • 학습 데이터: MS MARCO 스타일 한국어 triplet 4.84M개

인덱스 구성

벤치마크는 아래 3개의 인덱스를 구성하여 병렬로 운영합니다. 각 인덱스는 독립적으로 운영되고 Python Client에서 RRF로 Late fusion하여 측정합니다.

  • bench-bm25-comp-ko
  • bench-dense-comp-ko
  • bench-sparse-klue-comp-ko

OpenSearch 인덱스 매핑

아래 매핑 예제에서 number_of_shards, number_of_replicas는 본 실험이 사용한 샤드 설정 값 (1/2)입니다. 10K 문서 실험에는 적절하나 실제 운영 환경에는 인덱스의 크기에 따라 적절하게 조정하시기 바랍니다.

BM25 인덱스

한국어 Nori 분석기를 커스텀 analyzer로 설정합니다.

client.indices.create(index="bench-bm25-comp-ko", body={
    "settings": {
        "analysis": {
            "analyzer": {
                "korean_analyzer": {
                    "type": "custom",
                    "tokenizer": "nori_tokenizer",
                }
            }
        },
        "number_of_shards": 1,
        "number_of_replicas": 2,
    },
    "mappings": {
        "properties": {
            "doc_id": {"type": "keyword"},
            "content": {"type": "text", "analyzer": "korean_analyzer"},
        }
    },
})

Dense 인덱스 (Titan용)

k-NN 플러그인을 활성화하고 FAISS 엔진의 HNSW 알고리즘, inner product space를 사용합니다.

client.indices.create(index="bench-dense-comp-ko", body={
    "settings": {
        "index": {"knn": True},
        "number_of_shards": 1,
        "number_of_replicas": 2,
    },
    "mappings": {
        "properties": {
            "doc_id": {"type": "keyword"},
            "content": {"type": "text"},
            "embedding": {
                "type": "knn_vector",
                "dimension": 1024,
                "method": {
                    "name": "hnsw",
                    "engine": "faiss",
                    "space_type": "innerproduct",
                    "parameters": {"ef_construction": 128, "m": 16},
                },
            },
        }
    },
})

Sparse 인덱스 (SPLADE용)

rank_features 타입을 사용하면 각 토큰별 가중치를 inverted posting list 형태로 저장하고 dot product 쿼리로 검색할 수 있습니다.

client.indices.create(index="bench-sparse-klue-comp-ko", body={
    "settings": {
        "index": {"mapping.total_fields.limit": 100000},
        "number_of_shards": 1,
        "number_of_replicas": 2,
    },
    "mappings": {
        "properties": {
            "doc_id": {"type": "keyword"},
            "content": {"type": "text"},
            "sparse_embedding": {"type": "rank_features"},
        }
    },
})

문서마다 활성화되는 토큰 조합이 달라 동적 필드가 누적될 수 있기에 mapping.total_fields.limit 을 10만으로 설정합니다.

인코더 구현

Titan Embedding 인코더

Bedrock Runtime API를 호출하여 임베딩을 수행합니다.

import json
import boto3

class TitanEncoder:
    def __init__(self, region: str = "us-east-1"):
        self.client = boto3.client("bedrock-runtime", region_name=region)
        self.model_id = "amazon.titan-embed-text-v2:0"

    def encode(self, text: str, dim: int = 1024) -> list[float]:
        body = json.dumps({
            "inputText": text[:8192],
            "dimensions": dim,
            "normalize": True,
        })
        resp = self.client.invoke_model(modelId=self.model_id, body=body)
        return json.loads(resp["body"].read())["embedding"]

SPLADE Sparse 인코더

HuggingFace AutoModel로 MLM 헤드를 불러오고 log(1+ReLU) + max pooling을 적용합니다. [CLS], [SEP], 서브워드 prefix 토큰은 필터링합니다.

import torch
import torch.nn as nn
from transformers import AutoModelForMaskedLM, AutoTokenizer

class SparseEncoder:
    def __init__(self, model_path: str, device: str = "cuda"):
        self.device = device
        self.tokenizer = AutoTokenizer.from_pretrained(model_path)
        self.model = AutoModelForMaskedLM.from_pretrained(model_path).to(device).eval()
        self.relu = nn.ReLU()
        self.special_ids = {
            self.tokenizer.cls_token_id,
            self.tokenizer.sep_token_id,
            self.tokenizer.pad_token_id,
            self.tokenizer.unk_token_id,
        } - {None}
        self._tokens = self.tokenizer.convert_ids_to_tokens(range(self.tokenizer.vocab_size))

    @torch.no_grad()
    def encode(self, text: str, max_length: int = 256, top_k: int = 64) -> dict[str, float]:
        inputs = self.tokenizer(text, return_tensors="pt",
                                max_length=max_length, truncation=True).to(self.device)
        logits = self.model(**inputs).logits
        sparse = torch.log1p(self.relu(logits))
        mask = inputs["attention_mask"].unsqueeze(-1).float()
        vec = (sparse * mask).max(dim=1).values.squeeze().cpu()

        result = {}
        for idx in (vec > 0).nonzero(as_tuple=True)[0].tolist():
            if idx in self.special_ids:
                continue
            token = self._tokens[idx]
            if token and not token.startswith(("[", "<")):
                result[token] = vec[idx].item()

        # 상위 top_k 토큰만 유지하여 posting list 크기 제한
        if len(result) > top_k:
            result = dict(sorted(result.items(), key=lambda x: -x[1])[:top_k])
        return result

상위 64개 토큰만 유지하는 이유는 FLOPS 관점에서 더 긴 posting list를 만들어도 retrieval 품질 개선이 미미하고 latency 비용만 증가하기 때문입니다.

문서 색인 (Bulk Indexing)

모든 인덱스에 같은 doc_id로 문서를 넣고 각 표현 벡터를 추가합니다.

from opensearchpy.helpers import bulk

def index_docs(client, indices, doc_ids, texts, titan, sparse):
    dense_vecs = [titan.encode(t) for t in texts]
    sparse_vecs = [sparse.encode(t) for t in texts]

    # BM25
    bulk(client, [
        {"_index": indices["bm25"], "_id": d,
         "_source": {"doc_id": d, "content": t}}
        for d, t in zip(doc_ids, texts)
    ], chunk_size=200)

    # Dense
    bulk(client, [
        {"_index": indices["dense"], "_id": d,
         "_source": {"doc_id": d, "content": t, "embedding": v}}
        for d, t, v in zip(doc_ids, texts, dense_vecs)
    ], chunk_size=100)

    # Sparse
    bulk(client, [
        {"_index": indices["sparse"], "_id": d,
         "_source": {"doc_id": d, "content": t, "sparse_embedding": s}}
        for d, t, s in zip(doc_ids, texts, sparse_vecs)
    ], chunk_size=200)

    for name in indices.values():
        client.indices.refresh(index=name)

10,000 문서 기준 인코딩/색인 시간은 환경에 따라 달라지지만 이번 실험 환경에서는 Titan 약 30분(Bedrock 직렬 호출, 쓰로틀 포함), SPLADE 약 2분(NVIDIA L4 단일 GPU), OpenSearch bulk write 약 1분 수준이었습니다.

쿼리 구현

BM25 쿼리

def bm25_search(client, index: str, query: str, top_k: int = 100):
    body = {
        "size": top_k,
        "query": {
            "match": {
                "content": {
                    "query": query,
                    "analyzer": "korean_analyzer",
                }
            }
        },
    }
    return client.search(index=index, body=body)

Dense kNN 쿼리

def dense_search(client, index: str, query: str, titan, top_k: int = 100):
    qvec = titan.encode(query)
    body = {
        "size": top_k,
        "query": {"knn": {"embedding": {"vector": qvec, "k": top_k}}},
    }
    return client.search(index=index, body=body)

SPLADE Sparse 쿼리

쿼리 텍스트를 인코딩해 활성 토큰과 가중치를 얻은 뒤, 각 토큰을 rank_feature should 절로 변환하여 bool 쿼리로 만듭니다.

def sparse_search(client, index: str, query: str, sparse_enc, top_k: int = 100):
    query_tokens = sparse_enc.encode(query, max_length=64, top_k=64)
    if not query_tokens:
        return {"hits": {"hits": []}}

    should = [
        {"rank_feature": {"field": f"sparse_embedding.{tok}", "boost": weight}}
        for tok, weight in sorted(query_tokens.items(), key=lambda x: -x[1])[:64]
    ]
    body = {
        "size": top_k,
        "query": {"bool": {"should": should}},
    }
    return client.search(index=index, body=body)

쿼리 에서 max_length=64로 설정합니다. 쿼리는 일반적으로 문서보다 짧고, 긴 쿼리는 SPLADE의 expansion으로 인해 지나치게 많은 토큰을 활성화시켜 precision을 해치는 경향이 있기 때문입니다.

RRF 하이브리드 (클라이언트 측 Late Fusion)

각 방법의 검색 결과를 받아 Reciprocal Rank Fusion으로 병합합니다.

def rrf_fuse(searchers: list, query: str, top_k: int = 10, k: int = 60):
    all_ranks = []
    for search_fn in searchers:
        resp = search_fn(query)
        ranks = {hit["_source"]["doc_id"]: rank
                 for rank, hit in enumerate(resp["hits"]["hits"], start=1)}
        all_ranks.append(ranks)

    all_docs = set().union(*[r.keys() for r in all_ranks])
    max_rank = max(max(r.values(), default=100) for r in all_ranks) + 1

    fused = {}
    for doc_id in all_docs:
        fused[doc_id] = sum(
            1.0 / (k + r.get(doc_id, max_rank))
            for r in all_ranks
        )

    return sorted(fused.items(), key=lambda x: -x[1])[:top_k]

RRF 파라미터 k=60은 BEIR, MS MARCO 등의 표준 값이며 각 하위 검색에서 top-100을 가져와 융합합니다.

검증 결과

최종 벤치마크 결과

213 쿼리, 10,000 문서 기준으로 OpenSearch 벤치마크 결과는 아래와 같습니다.

Method R@1 R@5 R@10 MRR@10 nDCG@10
BM25 44.10% 80.80% 90.60% 0.5941 0.6211
Titan v2 (dense) 60.10% 88.70% 92.50% 0.7221 0.6962
klue-large (SPLADE) 62.90% 90.60% 94.80% 0.7366 0.709
BM25 + Titan 64.80% 88.30% 94.40% 0.7461 0.7412
BM25 + klue-large 63.80% 89.70% 95.30% 0.755 0.753
Titan + klue-large 66.20% 91.50% 96.20% 0.7609 0.741
BM25 + Titan + klue-large 66.20% 90.60% 96.20% 0.7658 0.7609

벤치마크 분석

단일 모델 비교

SPLADE 기반 klue-large가 모든 메트릭에서 Titan dense와 BM25를 상회합니다. R@1은 BM25 대비 +18.8pp, Titan 대비 +2.8pp 우위이고 nDCG@10도 klue-large가 0.7090으로 Titan(0.6962)보다 +1.3pp 높습니다. 의미 확장(lexical expansion)이 필요한 질문형 쿼리와 정확한 키워드 매칭이 필요한 쿼리 모두에서 SPLADE가 균형 있게 작동함을 확인할 수 있습니다.

하이브리드 효과

  • BM25 + klue-large (nDCG 0.7530): 단일 klue-large 대비 nDCG +4.4pp 개선. BM25가 어휘 정확도를 보완하여 상위 순위의 품질이 올라간 결과
  • Titan + klue-large (nDCG 0.7410): R@10이 96.2%로 단일 klue-large(94.8%)보다 높지만 nDCG는 BM25+klue-large보다 낮으나 Dense는 상위 순위 품질보다 recall 확장에 기여
  • 3-way (BM25 + Titan + klue-large, nDCG 0.7609): BM25+klue-large 대비 nDCG +0.8pp 추가 개선

마치며

SPLADE 계열 Neural Sparse 모델은 Dense Vector의 의미적 유사도 파악 능력과 BM25의 정확한 키워드 매칭을 동시에 제공하는 접근법입니다. 한국어 사전 학습된 모델(klue/roberta-large)을 기반으로 SPLADE v3 스타일로 파인튜닝한 sewoong/korean-neural-sparse-encoder-base-klue-large는 MIRACL-ko에서 Titan Embedding V2 대비 단일 모델 기준 더 높은 retrieval 품질을 보였습니다.

OpenSearch의 rank_features 매핑을 활용하면 Sparse 벡터 검색에 활용할 수 있고, k-NN, BM25와의 하이브리드 조합도 간단한 클라이언트 측 RRF로 구현할 수 있습니다. 이 글에서 제공한 코드를 기반으로 자체 도메인 데이터에 대해 동일한 벤치마크를 수행해 보시기 바랍니다.

참고 문헌

Sewoong Kim

Sewoong Kim

김세웅 AI/ML Specialist SA는 정보 검색 분야의 전문가로 분석 서비스와 컨테이너, 서버리스 중심으로 AWS 기반 서비스를 구성하는 고객들에게 최적화된 아키텍처를 제공하고, AI를 위한 데이터 분석 플랫폼을 보다 고도화 하기 위한 여러 기술적인 도움을 드리고 있습니다.