본문으로 바로가기
반응형

2편에서 사용자 문장을 검색용 문장·키워드·태그로 구조화하는 과정을 다뤘습니다. 이번 편에서는 이렇게 구조화된 정보를 가지고 벡터 검색과 키워드 검색을 어떻게 결합했는지 정리합니다.

 

왜 하나만 쓰면 안 됐나

처음엔 "벡터 검색만 쓰면 되지 않나" 싶었습니다. 의미 기반 검색이니 웬만한 건 다 커버될 거라 생각했는데, 실제로 테스트해보니 문제가 있었습니다.

  • "나이키 후드티" 같은 브랜드명·상품명 정확 매칭은 벡터 검색이 오히려 약했습니다. 의미가 비슷한 다른 상품이 끼어드는 경우가 많았습니다.
  • 반대로 "시원한 여름 셔츠"처럼 의미를 해석해야 하는 질의는 키워드 검색(BM25)만으로는 한계가 명확했습니다. "시원한"이라는 단어가 상품명에 그대로 없으면 못 찾습니다.

결국 "정확히 뭘 찾는지 아는 질의"에는 키워드 검색이, "느낌으로 찾는 질의"에는 벡터 검색이 강하다는 결론에 도달했고, 둘을 같이 쓰기로 했습니다.

 

질의마다 가중치를 다르게 주기

두 검색을 그냥 반반 섞으면 안 됐습니다. 질의의 구체성에 따라 가중치를 동적으로 바꿨습니다. 기준은 "추출된 태그가 몇 개인가"였습니다.

private double calcVectorWeight(KeywordExtractionResultDto dto) {
    long specificTagCount = Stream.of(
        tags.getProductCategory(), tags.getGender(), tags.getStyle(),
        tags.getMaterial(), tags.getSeason(), tags.getFit(),
        tags.getOccasion(), tags.getPattern()
    ).filter(t -> t != null && !t.isBlank()).count();

    if (specificTagCount >= 3) return 0.3;  // 태그 많음 → 키워드 검색이 더 정확
    if (specificTagCount >= 1) return 0.4;  // 태그 보통 → 균형
    return 0.5;                              // 태그 없음 → 의미 기반 검색에 의존
}
 
태그 수벡터 가중치키워드 가중치해석
3개 이상 0.3 0.7 조건이 구체적일수록 정확 매칭이 더 믿을만함
1~2개 0.4 0.6 균형
0개 0.5 0.5 모호한 질의는 의미 기반 검색에 기댈 수밖에 없음

 

이 로직을 넣기 전에는, 태그가 많이 잡힌 구체적인 질의에서도 벡터 검색 결과가 절반을 차지해서 엉뚱한 상품이 섞여 나오는 문제가 있었습니다. "질의가 구체적일수록 정확 매칭을 더 믿는다"는 방향으로 가중치를 조정하고 나서야 결과가 안정됐습니다.

 

두 결과를 어떻게 합칠까: RRF

벡터 검색과 키워드 검색은 점수 체계 자체가 다릅니다. 벡터 검색은 코사인 유사도(0~1), 키워드 검색은 BM25 점수(범위가 훨씬 큼)를 씁니다. 이 둘의 점수를 그대로 더하면 한쪽이 항상 우세해지는 문제가 생깁니다.

그래서 점수 자체가 아니라 순위(rank) 를 기준으로 합치는 RRF(Reciprocal Rank Fusion)를 썼습니다.

 
final int k = 60;

// 벡터 검색 결과의 순위 기반 점수 누적
for (int i = 0; i < vectorResults.size(); i++) {
    rrfScores.merge(id, vectorWeight / (k + i + 1), Double::sum);
}
// 키워드 검색 결과의 순위 기반 점수 누적
for (int i = 0; i < keywordResults.size(); i++) {
    rrfScores.merge(id, keywordWeight / (k + i + 1), Double::sum);
}
// 최종 점수 내림차순 정렬


원리는 단순합니다. 각 검색에서 몇 등을 했는지만 보고, 등수가 높을수록(1등에 가까울수록) 점수를 많이 주는 방식입니다. k=60은 등수 차이에 따른 점수 변화를 완만하게 만드는 상수인데, 이 값을 조정해가며 여러 번 테스트했습니다. 값을 너무 작게 주면 1등만 지나치게 유리해지고, 너무 크게 주면 등수 차이가 무의미해지는 느낌이라 60 정도에서 결과가 가장 자연스러웠습니다.

두 리스트에서 같은 상품이 중복으로 나오면 점수를 합산하고, 한쪽에만 있으면 그 점수만 반영됩니다. 그래서 "벡터 검색에서도, 키워드 검색에서도 상위권"인 상품이 자연스럽게 최상단으로 올라옵니다.

 

벡터 검색 안에서의 디테일

벡터 검색도 그냥 유사도 순으로만 정렬하면 안 됐습니다. 두 가지를 더 신경 썼습니다.

사전 필터링(pre-filter): 카테고리·색상 같은 명확한 조건은 kNN 쿼리 자체에 필터로 넣었습니다. 검색 이후에 걸러내는 방식(post-filter)보다, 애초에 후보군을 줄이고 시작하는 게 훨씬 효율적이었습니다.

인기도 부스팅: 의미 유사도가 비슷한 상품들 사이에서는, 인기 점수가 높은 상품을 살짝 우선하도록 점수를 보정했습니다. 완전히 처음 보는 브랜드보다 잘 팔리는 상품이 우선 노출되는 게 사용자 경험상 자연스러웠습니다.

반대로 "드라이클리닝 제외" 같은 부정 조건은 kNN 이후에 별도로 걸러내는 post-filter로 처리했습니다. 벡터 검색 자체는 "이거 빼고 찾아줘"라는 개념을 이해하지 못하기 때문에, 일단 후보를 뽑은 다음 사후 필터링하는 게 맞았습니다.

 

 

OpenSearch 인덱스 설계에서 신경 쓴 것

  • 한국어 형태소 분석: 상품명·브랜드명 필드에는 한국어 전용 토크나이저(nori)와 동의어 필터를 적용했습니다. 이게 없으면 "티셔츠"와 "티셔츠류" 같은 표현 차이에서 검색이 어긋납니다.
  • 필드별 boost 값 차등: 상품명·브랜드명 정확 매칭에는 가장 높은 가중치를, 세부 속성(소재, 세탁방법 등)에는 낮은 가중치를 줬습니다. "정확히 이름을 아는 검색"이 "세부 취향 검색"보다 항상 우선되어야 한다고 판단했습니다.

 

이번 편 정리

하이브리드 검색에서 가장 중요했던 건 "벡터 검색과 키워드 검색 중 뭐가 더 맞는 답을 줄지는 질의마다 다르다"는 걸 인정하고, 그 판단 자체를 자동화하는 것이었습니다. 점수 체계가 다른 두 검색을 순위 기반으로 합치는 RRF도, 단순히 좋은 알고리즘이라서 쓴 게 아니라 "점수를 그대로 더했더니 한쪽이 항상 이겼다"는 실패를 거친 뒤에 나온 선택이었습니다.

 

다음 편에서는 검색 결과를 어떻게 실시간으로 스트리밍했는지, 그리고 OpenAI를 추가로 호출하지 않고 개인화 추천을 어떻게 구현했는지 다루겠습니다.

반응형