이커머스 검색을 위한 희소 임베딩 파인튜닝 | 3부: 평가와 하드 네거티브

이커머스 검색을 위한 희소 임베딩 파인튜닝 | 3부: 평가와 하드 네거티브 (Fine-Tuning Sparse Embeddings for E-Commerce Search | Part 3: Evaluation and Hard Negatives)

2부에서 훈련했으니, 이제 "정말 더 좋은가?"라는 핵심 질문에 답할 차례예요. 이 글에서는 제품을 Qdrant에 인덱싱하고, nDCG·MRR·Recall 같은 검색 벤치마크를 돌리고, ANCE에서 착안한 하드 네거티브 마이닝을 구현해요. 표와 메트릭, 코드는 원문 그대로 가져왔어요.

출처: 공식문서

이커머스 검색을 위한 희소 임베딩 파인튜닝에 관한 5부작 시리즈의 3부입니다. 2부에서 Modal에서 SPLADE 모델을 훈련했어요. 이제 그것을 평가하고 하드 네거티브 마이닝으로 더 나아갑니다.

시리즈:


훈련된 SPLADE 모델이 Modal 볼륨에 있습니다(또는 HuggingFace에서 가져오거나요). 이제 중요한 질문이 남아요: 정말 더 나은가? 이 글에서 제품을 Qdrant에 인덱싱하고, 검색 벤치마크를 실행하고, 하드 네거티브 마이닝을 구현하고, 모델이 무엇을 배웠는지 파고들 거예요. 전체 평가 코드는 GitHub 저장소에 있어요. 이 전체 파이프라인을 자신의 데이터로 실행하려면 sparse-finetune CLI를 보세요.

Qdrant에 제품 인덱싱하기

평가하기 전에 제품이 검색 가능한 인덱스에 있어야 해요. Qdrant의 희소 벡터 지원 덕분에 이건 간단합니다.

from qdrant_client import QdrantClient, models

def index_products(model, products, collection_name="ecommerce_splade"):
    client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)

    # Create collection with sparse vector config
    client.create_collection(
        collection_name=collection_name,
        vectors_config={},
        sparse_vectors_config={
            "text": models.SparseVectorParams(
                index=models.SparseIndexParams(on_disk=True)
            )
        },
    )

    # Encode and index in batches
    for batch in chunked(products, batch_size=32):
        texts = [p["text"] for p in batch]
        embeddings = model.encode(texts)

        points = []
        for product, emb in zip(batch, embeddings):
            indices = emb["indices"].tolist()
            values = emb["values"].tolist()

            points.append(models.PointStruct(
                id=product["id"],
                vector={
                    "text": models.SparseVector(indices=indices, values=values)
                },
                payload={"title": product["title"], "brand": product["brand"]},
            ))

        upsert_with_retry(client, collection_name, points)

몇 가지 프로덕션 세부 사항:

  • **on_disk=True**는 역인덱스를 RAM 대신 디스크에 유지해요. SPLADE 벡터는 평균 200개의 활성 용어를 갖고, 수백만 개 제품에 걸치면 이건 누적돼요. 수용 가능한 지연 시간을 위해 SSD가 필요해요.
  • 업서트의 wait=False(upsert_with_retry 안)는 차단 없이 배치를 파이프라인으로 처리하게 해 줘요. 마지막 배치에서는 wait=True로 호출하세요.
  • 클라우드 데이터베이스에는 지수 백오프로 재시도하세요. 프로덕션에서는 네트워크 오류가 발생합니다.
def upsert_with_retry(client, collection_name, points, max_retries=5):
    """Upsert with exponential backoff."""
    for attempt in range(max_retries):
        try:
            client.upsert(collection_name=collection_name, points=points, wait=False)
            return
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            wait_time = (2 ** attempt) + (attempt * 0.5)
            time.sleep(wait_time)

검색 메트릭

10,000개 제품에 대한 2,000개의 테스트 질의로 표준 정보 검색 메트릭을 평가해요.

  • nDCG@k — 위치 편향이 있는 랭킹 품질; 상위 결과가 더 중요
  • MRR@k — 첫 번째 관련 결과가 얼마나 높게 나타나는지
  • Recall@k — 관련 제품 중 top-k에 몇 퍼센트가 나오는지
  • Precision@k — top-k 결과 중 몇 퍼센트가 관련 있는지

nDCG@10(Normalized Discounted Cumulative Gain)이 1차 메트릭이에요. 매우 관련 있는 제품(Exact 매칭)을 상단에 두는 것을 보상하고, 랭킹에서 더 낮게 나타난 관련 결과에는 패널티를 줘요. 완벽한 점수는 1.0이고, 이 데이터셋에서 무작위 랭킹은 대략 0.1을 주지요.

인덱스 검색하기

def search_products(query, model, client, collection_name="ecommerce_splade", limit=10):
    query_embedding = model.encode(query)

    results = client.query_points(
        collection_name=collection_name,
        query=models.SparseVector(
            indices=query_embedding["indices"].tolist(),
            values=query_embedding["values"].tolist(),
        ),
        using="text",
        limit=limit,
    )

    return [
        {"id": r.id, "score": r.score, "title": r.payload["title"]}
        for r in results.points
    ]

질의 문자열에서 랭킹된 제품까지 다섯 줄이에요. Qdrant 역인덱스의 희소 벡터 룩업은 수백만 개 제품에서도 서브-밀리초예요. 병목은 트랜스포머를 통한 10-20ms의 질의 인코딩이에요.

결과

2,000개의 테스트 질의로 평가한 결과는 다음과 같아요.

Model nDCG@10 MRR@10 vs BM25
BM25 (baseline) 0.305 0.313 -
SPLADE (off-the-shelf) 0.326 0.339 +7.2%
SPLADE (fine-tuned) 0.389 0.387 +27.5%

참고: 이 지표들은 모든 관련 문서가 포함된 10만 개 제품과 1만 개 질의의 하위 표본에서 측정된 것이에요. 공식 Amazon ESCI 벤치마크와 직접 비교할 수 없으며, 비교적 신호로만 취급해야 해요.

파인튜닝된 모델은 BM25를 거의 28% 이겨요. 더 의미 있는 것은, 바로 사용하는 SPLADE를 19% 이긴다는 점이에요. 바로 사용하는 모델은 이커머스가 아닌 MS MARCO(웹 검색 질의)로 훈련됐어요. 그 19%의 격차가 바로 도메인 특화 훈련의 가치예요.

하이브리드 검색은 어떤가?

자연스러운 질문이에요. 희소와 밀집 벡터를 결합하면 더 좋은 결과를 얻을 수 있을까요? Qdrant의 네이티브 Reciprocal Rank Fusion으로 테스트해 봤어요.

client.query_points(
    collection_name="products",
    prefetch=[
        models.Prefetch(query=sparse_vector, using="sparse", limit=100),
        models.Prefetch(query=dense_vector, using="dense", limit=100),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=10,
)

바로 사용하는 SPLADE에서는 하이브리드가 도움돼요: 희소 단독 대비 +1.3%. 두 시그널 모두 중간 강도이고, 둘을 결합하면 어느 한쪽이 놓치는 제품을 잡아냅니다.

파인튜닝된 SPLADE에서는 하이브리드가 오히려 해가 돼요: SPLADE 단독 0.413 대비 하이브리드 0.405. 파인튜닝된 희소 모델이 충분히 강해서, 일반적인 밀집 시그널을 더하면 랭킹이 희석돼요. 밀집 모델이 의미상 유사하지만 관련 없는 제품을 검색해서 nDCG를 끌어내리죠.

참고: 이 지표들은 모든 관련 문서가 포함된 10만 개 제품과 1만 개 질의의 하위 표본에서 측정된 것이에요. 공식 Amazon ESCI 벤치마크와 직접 비교할 수 없으며, 비교적 신호로만 취급해야 해요.

이건 유용한 발견이에요. 하이브리드 검색이 항상 더 나은 건 아니에요. 여러분 시그널의 상대적 강도에 달려 있어요. 희소 모델이 도메인에 튜닝됐고 밀집 모델이 일반적이라면, 밀집 구성 요소가 결과를 적극적으로 해칠 수 있어요.

ANCE에서 착안한 하드 네거티브 마이닝

2부의 훈련은 인배치 네거티브를 사용했어요. 같은 배치의 다른 제품이 주어진 질의의 네거티브 역할을 하는 거죠. 이건 동작하지만 한계가 있어요. 무작위 제품은 쉬운 네거티브(easy negative)예요. 모델은 진짜로 혼동할 만한 제품들을 구별하는 법을 배우지 못해요.

ANCE에서 착안한 이 접근 방식은 현재 모델 자신의 검색 결과에서 하드 네거티브를 채굴합니다.

  1. 현재 모델로 제품을 Qdrant에 인덱싱
  2. 각 질의에 대한 top-K 제품 검색
  3. 관련 없는 제품으로 필터링 — 이것들이 하드 네거티브
  4. (질의, 긍정, 하드_네거티브) 삼중항으로 훈련
  5. 업데이트된 모델로 반복

각 라운드는 모델이 개선되면서 더 어려운 네거티브를 채굴해요.

아이디어는 이거예요. 현재 모델이 질의에 대해 제품을 검색했는데 그 제품이 관련이 없다면, 그것은 하드 네거티브예요. 모델은 그 제품이 관련 있다고 생각했던 것이므로, 그걸로 훈련하면 모델에게 자신의 실수가 어디인지 가르쳐 줍니다.

마이닝 구현

from src.qdrant.mining import SparseQdrantMiner

# Index products with current model
index_sparse_vectors(client, collection_name, model, products)

# Mine hard negatives
miner = SparseQdrantMiner(client, model, collection_name)
hard_neg_examples = miner.mine_for_training(
    queries=queries_with_positives,
    top_k=20,           # Consider top-20 results
    num_negatives=3,    # Keep 3 hardest negatives per query
)

# hard_neg_examples now contains:
# [{"anchor": "wireless earbuds",
#   "positive": "Sony WF-1000XM5 Earbuds...",
#   "negative": ["Generic Bluetooth Earbuds...", ...]}, ...]

희소 검색은 마이닝을 저렴하게 유지해요. Qdrant에서 질의당 서브-밀리초예요. 10만 개 질의에 대해 마이닝 단계는 몇 분이 아니라 몇 초가 걸려요. 페이로드 필터는 알려진 긍정을 제외해, 관련 제품을 실수로 네거티브 취급하지 않게 합니다.

ANCE식 마이닝은 언제 쓰나

이 접근 방식은 복잡성을 더해요. 다음을 해야 합니다.

  1. 현재 모델로 제품 인덱싱
  2. 모든 훈련 질의에 대해 검색 실행
  3. 결과 필터링 및 포맷팅
  4. 확장된 데이터셋으로 재훈련
  5. 선택적으로 반복

이것은 기본 훈련 위에 추가로 5~10% 개선을 줘요. 그게 엔지니어링 노력만한 가치가 있는지는 유스케이스에 달려 있어요. 수백만 질의를 서빙하는 제품 검색 시스템이라면, 5%의 nDCG 개선은 의미 있게 더 나은 사용자 경험과 전환율로 이어집니다.

파인튜닝이 실제로 바꾸는 것

파인튜닝 전후의 모델 출력을 보면 모델이 무엇을 배웠는지 드러나요.

질의 확장이 개선된다:

  • "laptop" → "notebook", "computer", "macbook" 추가
  • "wireless earbuds" → "bluetooth", "airpods", "tws" 추가

용어 가중치가 예리해진다:

  • 브랜드명이 더 높은 가중치를 얻음 (사용자가 "Sony headphones"를 검색하면 Sony를 원함)
  • 일반 용어는 더 낮은 가중치 ("good", "best", "cheap")

도메인 어휘가 등장한다:

  • "refurbished", "renewed", "bundle" 같은 이커머스 용어가 의미 있는 가중치를 얻음
  • 웹 검색 특화 용어는 가중치가 낮아짐

이 도메인 적응이 강한 도메인 내 결과를 설명하고, 4부에서 볼 수 있듯이 모델을 다른 도메인에 적용할 때의 트레이드오프도 설명해요.

프로덕션 지연 시간

흔한 우려 사항이 있어요. 매 질의마다 트랜스포머를 돌리는 게 느리지 않나요?

Step Latency Note
Query encoding (SPLADE) 10-20ms Bottleneck
Sparse retrieval (Qdrant) <1ms Negligible
Total 10-20ms Real-time

검색 자체는 무시할 만해요. Qdrant의 Rust + SIMD 최적화 역인덱스는 서브-밀리초에 수백만 개의 포스팅 리스트를 훑어요. 지연 시간의 전부는 인코더에 있는데, 이는 카탈로그 크기와 무관하게 질의당 한 번 실행돼요.

15ms가 충분히 빠르지 않다면 최적화 전략:

  • 질의 배칭: 여러 질의를 함께 인코딩 (자동완성, 관련 검색)
  • 증류(Distillation): 더 작은 인코더(TinyBERT, MiniLM)를 훈련해 SPLADE의 출력을 모방
  • 캐싱: 인기 질의를 희소 벡터 수준에서 캐시
  • GPU 추론: 대량 트래픽 시스템에서 5-10배 속도 향상

대부분의 이커머스 애플리케이션에서 15ms는 충분해요. 특히 28% 더 나은 관련성을 제공한다면요.

핵심 요점

  • 파인튜닝된 SPLADE는 BM25를 28%, 바로 사용하는 SPLADE를 19% 이긴다. 희소 모델에서도 도메인 특화 훈련이 중요해요.
  • 하이브리드 검색이 항상 더 나은 건 아니다. 밀집 구성 요소가 일반적이라면, 강한 도메인 튜닝 희소 모델이 희소+밀집 융합을 능가할 수 있어요.
  • 하드 네거티브 마이닝(ANCE식)은 기본 훈련 위에 5-10%를 더한다. Qdrant의 희소 검색이 마이닝 단계를 저렴하게 만들어요.
  • 프로덕션 지연 시간은 총 10-20ms다. 병목은 검색이 아니라 트랜스포머 인코딩이에요.
  • 모델은 도메인 특화 패턴을 학습한다: 질의 확장, 용어 가중치, 이커머스 어휘가 모두 파인튜닝으로 개선돼요.

다음: Part 4 - 전문화 vs 일반화

더 알아보기 (Learn more)