나는 Vector-Only Search를 먼저 만들었다. 왜 다시 작성해야 했는지

발행: (2026년 2월 20일 오후 03:32 GMT+9)
9 분 소요
원문: Dev.to

Source: Dev.to

저는 3주 동안 전자상거래 제품 카탈로그를 위한 순수 벡터 검색을 구축했으며, 모든 항목을 intfloat/multilingual-e5-large 로 임베딩하고, 벡터를 Qdrant에 저장한 뒤 몇 가지 테스트 쿼리를 실행했습니다.

  • “요리를 좋아하는 사람에게 줄 선물” → 주방칼, 스파이스 세트 – ✅
  • “Nike Air Max 90 black” → 아디다스 러닝 슈즈 – ❌
  • “XJ‑4520” (실제 SKU) → 무작위 주방 가전 – ❌

엔진은 의도를 이해했지만 가장 간단한 정확히 일치하는 검색에서는 실패했습니다.

왜 벡터 전용 검색이 실패하는가

서술형 쿼리는 작동한다

임베딩은 텍스트를 고차원 공간에 매핑하여 의미적으로 유사한 의미가 군집하도록 합니다. “요리를 좋아하는 사람에게 줄 선물” 같은 쿼리는 제품 제목에 gift 라는 단어가 전혀 없더라도 칼, 요리책, 향신료 세트 근처에 위치합니다.

SKU 및 모델 번호

XJ‑4520 같은 SKU는 임베딩 모델에겐 의미 없는 문자열에 불과합니다. 벡터 공간 어딘가에 투영되고, 가장 가까운 이웃은 우연히 근처에 있는 다른 의미 없는 문자열들입니다. 실제로 SKU 조회가 올바른 제품을 반환하는 경우는 거의 없습니다.

브랜드 + 속성 조합

“Nike Air Max 90 black size 42”는 하나의 제품만 반환해야 합니다. 그러나 벡터 검색은 Nike 제품뿐 아니라 Adidas와 Puma 신발까지 반환했는데, 이들은 모두 의미적으로 “운동화”로 군집했기 때문입니다. 정확히 일치하는 결과는 종종 2페이지로 밀려났습니다.

숫자 필터

“$50 이하” 혹은 “500 ml 병” 같은 쿼리는 숫자 제약으로 인식되지 않습니다. 모델은 500 ml액체와 관련 있다는 것은 알지만 실제 숫자로 필터링하지는 못합니다.

짧고 구체적인 쿼리

“Bosch”와 같은 단일 토큰은 벡터 검색에서는 무작위 전동공구를 반환하지만, BM25 인덱스라면 관련도에 따라 모든 Bosch 제품을 반환합니다.

BM25와 벡터 검색 결합

병렬 실행

동일한 카탈로그에 BM25 인덱스와 벡터 인덱스를 각각 실행한 뒤 결과를 병합합니다. BM25는 정확한 매치(SKU, 브랜드명, 특정 속성)에 강점이 있으며, 벡터는 서술형, 의도 기반 및 다국어 쿼리를 처리합니다.

점수 정규화

BM25 점수는 대략 0–25+ 범위이며, 벡터 유사도는 0과 1 사이에 제한됩니다. 두 점수를 0‑1 범위의 공통 스케일로 정규화하는 것이 결합하기 전에 필수적입니다.

def normalize_scores(results: dict[str, float]) -> dict[str, float]:
    """Scale scores to the 0‑1 interval."""
    if not results:
        return {}
    min_score = min(results.values())
    max_score = max(results.values())
    if max_score == min_score:
        return {k: 1.0 for k in results}
    return {
        k: (v - min_score) / (max_score - min_score)
        for k, v in results.items()
    }

가중치 조정 가능

정규화 후에는 매장별 가중치를 적용해 두 점수 흐름을 혼합합니다. 부품 번호에 크게 의존하는 부품 공급업체는 BM25에 더 큰 가중치를 부여하고, 원하는 스타일을 서술하는 패션 소매업체는 벡터 쪽을 선호합니다.

교차 인코더 재정렬

상위 k개의 병합 후보에 교차 인코더(예: cross-encoder/ms-marco-MiniLM-L-6-v2)를 적용합니다. 모델은 각 후보와 쿼리를 직접 비교해 결과를 재정렬함으로써 단순 병합으로 인해 순위가 잘못 매겨진 경우를 바로잡습니다.

실용적인 고려사항

검색 전 쿼리 분석

  • SKU와 같은 쿼리 (알파벳·숫자 혼합, 공백 없음) → 벡터 검색을 완전히 건너뜁니다.
  • 길고 설명적인 문장 → 벡터 가중치를 높입니다.
  • 혼합된 쿼리 (예: “red Nike something for running”) → 두 엔진을 모두 균형 있게 사용합니다.

단일 단어 쿼리

벡터 검색은 단일 토큰에 취약하므로 BM25 또는 대체 휴리스틱에 더 많이 의존합니다.

혼합 결과 집합 처리

BM25가 50개의 히트를 반환하고 벡터가 3개만 반환하면 병합 시 BM25에 편향될 수 있습니다. 정규화와 가중치 조정을 통해 이를 완화할 수 있습니다.

오타 및 철자 오류

BM25와 벡터 모델 모두 심한 철자 오류가 있으면 성능이 저하됩니다. 맞춤법 교정 레이어나 퍼지 매칭을 추가하는 것을 고려하세요.

개인화

설명된 파이프라인은 각 쿼리를 독립적으로 처리합니다. 사용자 히스토리 신호를 추가하려면 별도의 랭킹 레이어가 필요합니다.

임베딩 캐싱

임베딩 계산은 비용이 많이 듭니다. 제품 텍스트의 벡터 표현을 캐시하되, 카탈로그 데이터가 변경될 때 강력한 무효화 메커니즘을 구현하세요.

기술 스택

  • 벡터 스토어 및 BM25: Qdrant (내장 BM25 지원)
  • 임베딩: intfloat/multilingual-e5-large (1024 차원, 100개 이상의 언어)
  • 재정렬기: cross-encoder/ms-marco-MiniLM-L-6-v2
  • 언어: Python (비동기 검색 실행)
  • 오케스트레이션: LangGraph (더 큰 채팅 어시스턴트 워크플로에 통합)

Conclusion

벡터만을 이용한 솔루션으로 시작하지 마세요. 임베딩은 의도 파악과 다국어, 서술형 쿼리 처리에 뛰어나지만, 정확히 일치하는 조회, 숫자 제약, 짧은 토큰에서는 한계가 있습니다. BM25vector 검색을 병렬로 실행하고, 점수를 정규화하며, 구성 가능한 가중치를 적용하고, 필요에 따라 cross‑encoder로 재정렬하는 하이브리드 접근 방식은 견고하고 프로덕션‑ready 검색 경험을 제공합니다.

e‑commerce 검색에서 비슷한 문제를 겪으셨다면, 어떻게 해결했는지 알려주세요.

0 조회
Back to Blog

관련 글

더 보기 »

따뜻한 소개

소개 여러분, 안녕하세요! 여기서 진행되는 deep tech 토론에 매료되었습니다. 커뮤니티가 번창하는 모습을 보는 것은 정말 놀랍습니다. 프로젝트 개요 저는 열정적입니다...