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를 추가로 호출하지 않고 개인화 추천을 어떻게 구현했는지 다루겠습니다.
'ELK' 카테고리의 다른 글
| [자연어 검색 개발기 #4] 스트리밍, 추천, 그리고 회고 (0) | 2026.07.13 |
|---|---|
| [자연어 검색 개발기 #2] LLM에게 "의도"부터 물어보게 만들기 (1) | 2026.07.11 |
| [자연어 검색 개발기 #1] "AI로 뭐라도 만들어봐라"는 과제, 자연어 검색을 붙이기로 했다 (0) | 2026.07.10 |
| Elasticsearch CircuitBreaker GC 관련 (3) | 2023.10.26 |
| Elasticsearch Essential 트러블 슈팅 사례 (0) | 2023.10.26 |
