티스토리 뷰
안녕하세요! Spring AI를 통해 LLM + RAG를 활용하여 상품 추천 시스템을 만든 과정, 문제 그리고 해결 과정을 포스팅하려고 합니다.
AI 추천 시스템은 사용자가 검색을 했을 경우 그 검색어와 더불어 사용자의 과거 검색 정보, 조회한 상품, 장바구니 상품, 나이, 연령 등을 참고하여 LLM을 사용하여 상품을 추천하는 것이 핵심 목표였습니다.
설정 환경은 아래와 같습니다!
- Java 21
- Spring Boot 3.5.7
- Elasticsearch 8.x
- Qdrant(Vector DB)
- Spring AI (운영:OpenAI Model)
1. 배경: 왜 RRF(Reciprocal Rank Fusion) 검색인가?
상품 추천 기능을 기획할 때, 사용자가 단순 키워드(ex 갤럭시 S24)만을 사용해서 검색하는 것뿐 아니라, 의미 기반 검색(ex 가성비 좋은 폰) 또한 고려해야 했습니다. 의미 기반 검색은 Vector DB에서 상품을 조회한 후 처리하면 되지만, 단순 키워드 검색은 Vector DB만으로는 이커머스 검색을 감당하기 어렵다는 한계를 발견했습니다. 사실, 쇼핑몰 사용자의 검색 패턴은 '아이폰 15', '삼성전자 공기청정기'처럼 정확한 브랜드나 모델명을 입력하는 경우가 대다수인데, 의미 유사도에 기반한 Vector Search는 정확한 상품을 조회하지 않은 경우가 있었습니다. score 0.75 이상 나오지 않았습니다.
따라서, 저는 키워드 검색에 강점이 있는 Elasticsearch를 사용해서 정확도를 잡고, 보조적으로 Vector Search를 활용하는 하이브리드 검색(Hybrid Search) 구조를 채택했습니다. 그리고 이 서로 다른 두 검색 엔진의 결과를 공정하게 합치기 위해 RRF(Reciprocal Rank Fusion) 알고리즘을 사용했습니다.
RRF(Reciprocal Rank Fusion)란?
RRF는 각 검색 엔진이 산출한 점수(Score)가 아닌, 아이템의 '상대적 순위(Rank)'만을 이용해 결과 리스트를 통합하는 알고리즘입니다. 각 리스트에서 아이템의 순위에 상수(k)를 더한 값의 역수(1 / (k + rank))를 합계 점수로 사용합니다. '상수(k)'는 보통 60을 사용하는데, 이는 순위가 너무 낮은 아이템이 상위에 갑자기 올라오는 것을 방지하는 역할을 합니다.
이 방식을 채택한 이유는 크게 두 가지입니다.
- 점수 체계의 통합: 수백 점 단위인 Elasticsearch의 BM25 점수와 0~1 사이의 값을 가지는 Vector Search의 코사인 유사도는 직접 비교가 불가능합니다. RRF는 이를 '순위'라는 공통 지표로 치환하여 별도의 복잡한 정규화 과정 없이도 공정한 결합을 가능하게 합니다.
- 교집합의 가중치: 두 가지 검색 방식 모두에서 공통적으로 상위권에 오른 상품은 더 높은 점수를 얻게 됩니다. 이는 키워드 적합성과 의미적 유사성을 동시에 만족하는 가장 확실한 결과물을 최상단에 배치하는 효과를 줍니다.
@Component
public class RRFMerger {
/*
* RRF 알고리즘으로 결과 병합
* k = 60 (일반적인 상수)
*/
public List<DocumentSearchResponse> mergeWithRRF(
List<ProductPostEsSearchResponse> esResults,
List<DocumentSearchResponse> vectorResults,
int limit,
double esWeight,
double vectorWeight) {
final int k = 60;
// 가중치는 파라미터로 전달받음
Map<String, Double> rrfScores = new LinkedHashMap<>();
Map<String, DocumentSearchResponse> docMap = new LinkedHashMap<>();
// es 결과 점수 계산
for (int rank = 0; rank < esResults.size(); rank++) {
ProductPostEsSearchResponse esDoc = esResults.get(rank);
String productId = esDoc.id();
double score = esWeight * (1.0 / (k + rank + 1)); // 가중치 적용
rrfScores.merge(productId, score, Double::sum);
// ES 결과를 DocumentSearchResponse로 변환
if (!docMap.containsKey(productId)) {
docMap.put(productId, convertToDocumentSearchResponse(esDoc));
}
}
// vector 결과 점수 계산
for (int rank = 0; rank < vectorResults.size(); rank++) {
DocumentSearchResponse vectorDoc = vectorResults.get(rank);
String productId = (String) vectorDoc.metadata().get("productId");
if (productId == null)
continue;
double score = vectorWeight * (1.0 / (k + rank + 1)); // 가중치 적용
rrfScores.merge(productId, score, Double::sum);
// vector 결과가 더 상세한 정보를 가질 수 있으므로 우선 사용
docMap.putIfAbsent(productId, vectorDoc);
}
// RRF 점수로 정렬 후, RRF 점수를 반영한 새 객체 반환
return rrfScores.keySet().stream()
.sorted(Comparator.comparing(rrfScores::get).reversed())// 내림차순
.limit(limit)
.map(productId -> {
DocumentSearchResponse original = docMap.get(productId);
if (original == null)
return null;
Double rrfScore = rrfScores.get(productId);
return new DocumentSearchResponse(
original.id(),
original.content(),
original.metadata(),
rrfScore);
})
.filter(Objects::nonNull)
.toList();
}
private DocumentSearchResponse convertToDocumentSearchResponse(ProductPostEsSearchResponse esDoc) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("productId", esDoc.id());
metadata.put("title", esDoc.title());
metadata.put("categoryName", esDoc.categoryName());
metadata.put("price", esDoc.price());
metadata.put("tags", esDoc.tags());
return new DocumentSearchResponse(
esDoc.id(),
esDoc.description(),
metadata,
esDoc.score());
}
}
2. 문제 상황
2.1 정적 가중치 RRF의 한계
하이브리드 검색을 도입했지만, 단순히 두 결과를 고정된 비율로 합치는 방식(Static RRF)을 사용하다 보니 새로운 문제가 발생했습니다.
키워드 검색과 의미 기반 검색의 중요도는 "사용자가 무엇을 입력했느냐"에 따라 완전히 달라져야 하는데, 이를 항상 똑같이 처리했기 때문입니다.
벡터 검색이 필요한 경우
"사진 잘 나오는 핸드폰"과 같이 추상적이거나 의미 기반의 질의가 들어왔을 때, 키워드 매칭 점수가 노이즈로 작용했습니다.
Elasticsearch에서 검색 시 제품 설명에 '사진'이라는 키워드가 있는 경우가 노이즈가 됩니다.
- Query: "사진 잘 나오는 핸드폰"
- Result: "집 정리 커튼" (제품 설명에 '사진'이라는 단어가 포함되어 키워드 점수가 높게 측정됨)
3. 해결 방안
이 문제를 해결하기 위해, 모든 검색어를 동일하게 처리하는 것이 아니라 사용자의 검색 의도를 먼저 파악하고(Intent Classification), 그에 맞춰 엔진의 가중치를 조절(Dynamic RRF)하는 전략을 세웠습니다.
3.1 LLM 기반 검색 의도 분류(Intent Classification)
LLM을 사용하여 실시간으로 쿼리를 분석하고, 사용자 쿼리를 EXACT(정확한 매칭) 또는 SEMANTIC(의미 기반)의 두 가지 카테고리로 분류했습니다.
정확한 브랜드 명이나 모델명이 쿼리에 포함됐을 경우 EXACT, 특징이나 추상적 표현은 SEMANTIC으로 분류합니다.
String prompt = """
Classify the search query "%s" into one of two categories:
1. EXACT: Query contains specific brand names (e.g., Samsung, Apple), model names (e.g., S24, iPhone), or product codes.
2. SEMANTIC: Query describes features, usage, or abstract concepts (e.g., good camera, cheap laptop) without specific brands/models.
Respond ONLY with the word EXACT or SEMANTIC.
"""
.formatted(query);
3.2 동적 가중치(Dynamic Weighted RRF)
분류된 의도를 바탕으로 RRF 계산 시 적용할 가중치를 동적으로 변경했습니다.
- EXACT일 경우: Elasticsearch 가중치 ↑ (3.0), Vector 가중치 ↓ (1.0)
- SEMANTIC일 경우 : Vector 가중치 ↑ (3.0), Elasticsearch 가중치 ↓ (1.0)
저 같은 경우 의도가 정확히 맞았을 경우 품질 성능 효과를 보기 위해 가중치를 3배로 주었습니다. 검색 쿼리는 길지 않기 때문에 충분히 LLM을 통한 의도 파악을 신뢰할 만 하다고 판단했습니다.
가중치 적용 로직은 아래와 같습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class HybridSearchProcessor {
private final DomainServiceClient domainServiceClient;
private final VectorRepository vectorRepository;
private final RRFMerger rrfMerger;
private final ChatModel chatModel;
public List<DocumentSearchResponse> search(SearchParams searchParams, int limit) {
log.info("하이브리드 검색 시작(searchParams): {}", searchParams);
//가중치 적용
WeightResult weightResult = getQueryIntent(searchParams.q());
//벡터 검색
CompletableFuture<List<DocumentSearchResponse>> vectorFuture = CompletableFuture.supplyAsync(() -> {
List<DocumentSearchResponse> result = vectorRepository.similaritySearch(searchParams, limit);
log.info("벡터 검색 결과: {} 건", result.size());
return result;
});
//es 검색
CompletableFuture<List<ProductPostEsSearchResponse>> esFuture = CompletableFuture
.supplyAsync(() -> searchFromElasticsearch(searchParams.q(), limit));
List<DocumentSearchResponse> vectorResults = vectorFuture.join();
List<ProductPostEsSearchResponse> esResults = esFuture.join();
if (esResults.isEmpty()) {
log.warn("ES 검색 실패 또는 결과 없음, 벡터 검색 결과만 사용");
return vectorResults;
}
log.info("ES 검색 결과: {} 건", esResults.size());
//RRF 적용
List<DocumentSearchResponse> mergedResults = rrfMerger.mergeWithRRF(
esResults,
vectorResults,
limit,
weightResult.esWeight(),
weightResult.vectorWeight());
log.info("병합된 결과: {} 건", mergedResults.size());
return mergedResults;
}
//가중치 계산
private WeightResult getQueryIntent(String q) {
double esWeight = 1.0;
double vectorWeight = 1.0;
try {
String intent = classifyQueryIntent(q);
log.info("검색어 의도 분류 결과: {}", intent);
if (intent.contains("EXACT")) {
esWeight = 3.0; // 키워드 매칭 우선 (강화)
vectorWeight = 1.0;
} else if (intent.contains("SEMANTIC")) {
esWeight = 1.0;
vectorWeight = 3.0; // 의미 기반 검색 우선 (강화)
}
} catch (Exception e) {
log.warn("의도 분류 실패, 기본 가중치 사용", e);
}
return new WeightResult(esWeight, vectorWeight);
}
//검색어 분류
private String classifyQueryIntent(String query) {
// 브랜드나 구체적인 모델명이 포함되면 EXACT로 유도
String prompt = """
Classify the search query "%s" into one of two categories:
1. EXACT: Query contains specific brand names (e.g., Samsung, Apple), model names (e.g., S24, iPhone), or product codes.
2. SEMANTIC: Query describes features, usage, or abstract concepts (e.g., good camera, cheap laptop) without specific brands/models.
Respond ONLY with the word EXACT or SEMANTIC.
"""
.formatted(query);
return chatModel.call(prompt);
}
//사용자 장바구니 등 정보 조회
private List<ProductPostEsSearchResponse> searchFromElasticsearch(String query, int limit) {
try {
PageResponse<List<ProductPostEsSearchResponse>> response = domainServiceClient.search(query, limit);
return response.contents() != null ? response.contents() : List.of();
} catch (Exception e) {
log.error("domain-service를 통한 Elasticsearch 검색 실패", e);
return List.of();
}
}
private record WeightResult(double esWeight, double vectorWeight) {
}
}

4. 성능 비교 및 결과
4.1 검색 결과 비교 실험
| 검색어 | 분류 | Vector 단독 | Elasticsearch 단독 | 정적 가중치 RRF | 동적 가중치 RRF |
| 갤럭시 S24 | EXACT | 갤럭시 S24+(유사) | 갤럭시 S24 | 갤럭시 S24 | 갤럭시 S24 (정확도 유지) |
| 사진 잘 나오는 폰 | SEMANTIC | 샤오미 레드미 |
집 정리 중 나온 커튼
(제품 설명에 '사진'이 들어감) |
집 정리 중 나온 커튼 | 샤오미 레드미 (의도 반영) |
| 아이폰 15 프로 | EXACT | 아이폰 15 화이트 | 아이폰 15 블루 | 아이폰 15 블루 | 아이폰 15 블루 |
- Vector 단독의 한계: 정확한 모델명 검색 시 유사품(S24+)이 섞여 나오는 문제가 있었습니다.
- Elasticsearch 단독의 한계: 의미형 검색어(사진 잘 나오는 폰)에서 텍스트가 일치하는 엉뚱한 상품(커튼)을 반환했습니다.
- 동적 가중치 RRF: 의도에 따라 강점을 가진 엔진에 힘을 실어줌으로써, 두 상황 모두에서 최적의 결과를 도출했습니다.
4.2 결과
- 단순 키워드 매칭 실패율 0% 달성: 문맥형 검색어 처리 능력이 대폭 향상되었습니다.
- MRR(Mean Reciprocal Rank) 50% 이상 향상(로컬 환경: bge-m3, 벡터 차원 : 1024 )(운영 환경(text-embedding-3-small, 벡터 차원: 1536)에서 40% 이상 향상)
MMR이란 첫 번째 의도한 결과가 몇 번째 등수에 나왔는지를 수치화한 것입니다. 예를 들어, '사진 잘 나오는 폰'이라고 검색했을 때, '아이폰'이 나오길 원한다고 하면, 제품 타이틀에 '아이폰'이 나왔을 때, 그것을 RRF로 수치화한 것입니다.
만약 상위 첫 번째(1등)에 '아이폰'이 바로 나왔다면 1.0, 상위 3번째에 나왔다면 0.33이런식으로 부여됩니다.
아래 이미지는 EXACT와 SEMENTIC 조회 결과에 대한 평균 RRF점수를 의미합니다. 또한, SEMENTIC 결과에 '아이폰'이 나오길 예상하고 측정한 수치입니다. 정적 RRF보다 동적 RRF가 약 MMR 수치가 약 50% 이상 향상했습니다.

만약 검색 결과에 '샤오미'를 기대하고 동일한 테스트를 진행한다면,

MMR 테스트 로직은 다음과 같습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class HybridSearchProcessor {
private final DomainServiceClient domainServiceClient;
private final VectorRepository vectorRepository;
private final RRFMerger rrfMerger;
private final ChatModel chatModel;
public List<DocumentSearchResponse> search(SearchParams searchParams, int limit) {
log.info("하이브리드 검색 시작(searchParams): {}", searchParams);
WeightResult weightResult = getQueryIntent(searchParams.q());
CompletableFuture<List<DocumentSearchResponse>> vectorFuture = CompletableFuture.supplyAsync(() -> {
List<DocumentSearchResponse> result = vectorRepository.similaritySearch(searchParams, limit);
log.info("벡터 검색 결과: {} 건", result.size());
return result;
});
CompletableFuture<List<ProductPostEsSearchResponse>> esFuture = CompletableFuture
.supplyAsync(() -> searchFromElasticsearch(searchParams.q(), limit));
List<DocumentSearchResponse> vectorResults = vectorFuture.join();
List<ProductPostEsSearchResponse> esResults = esFuture.join();
if (esResults.isEmpty()) {
log.warn("ES 검색 실패 또는 결과 없음, 벡터 검색 결과만 사용");
return vectorResults;
}
log.info("ES 검색 결과: {} 건", esResults.size());
List<DocumentSearchResponse> mergedResults = rrfMerger.mergeWithRRF(
esResults,
vectorResults,
limit,
weightResult.esWeight(),
weightResult.vectorWeight());
log.info("병합된 결과: {} 건", mergedResults.size());
return mergedResults;
}
private WeightResult getQueryIntent(String q) {
double esWeight = 1.0;
double vectorWeight = 1.0;
try {
String intent = classifyQueryIntent(q);
log.info("검색어 의도 분류 결과: {}", intent);
if (intent.contains("EXACT")) {
esWeight = 3.0; // 키워드 매칭 우선 (강화)
vectorWeight = 1.0;
} else if (intent.contains("SEMANTIC")) {
esWeight = 1.0;
vectorWeight = 3.0; // 의미 기반 검색 우선 (강화)
}
} catch (Exception e) {
log.warn("의도 분류 실패, 기본 가중치 사용", e);
}
return new WeightResult(esWeight, vectorWeight);
}
private String classifyQueryIntent(String query) {
// 브랜드나 구체적인 모델명이 포함되면 EXACT로 유도
String prompt = """
Classify the search query "%s" into one of two categories:
1. EXACT: Query contains specific brand names (e.g., Samsung, Apple), model names (e.g., S24, iPhone), or product codes.
2. SEMANTIC: Query describes features, usage, or abstract concepts (e.g., good camera, cheap laptop) without specific brands/models.
Respond ONLY with the word EXACT or SEMANTIC.
"""
.formatted(query);
return chatModel.call(prompt);
}
private List<ProductPostEsSearchResponse> searchFromElasticsearch(String query, int limit) {
try {
PageResponse<List<ProductPostEsSearchResponse>> response = domainServiceClient.search(query, limit);
return response.contents() != null ? response.contents() : List.of();
} catch (Exception e) {
log.error("domain-service를 통한 Elasticsearch 검색 실패", e);
return List.of();
}
}
private record WeightResult(double esWeight, double vectorWeight) {
}
}
5. 정리
AI 서비스에서 검색 품질을 높이기 위해 시행착오를 겪었습니다.
Vector DB가 의미 기반 검색이란 것을 이번에 시행착오를 겪으며 다시 한 번 저의 머리에 각인 시킬 수 있었습니다.
Vector Search의 정밀도가 중요한 상품명 검색에서 스코어 0.75의 벽을 넘지 못하는 한계를 보았을 때, 조회의 정밀도를 높이기 위한 로직 선책이 얼마나 중요한지 깨달았습니다.
하이브리드 검색 구조에서 동적 RRF(Dynamic RRF)를 적용한 것은 우리 서비스의 핵심인 '검색 기반 추천'에 가장 적합한 전략이라고 생각합니다. 이를 통해 사용자의 의도가 브랜드나 모델명에 있는지, 아니면 상품의 특징(의미)에 있는지에 따라 검색 엔진의 비중을 실시간으로 조절하여 품질을 극대화할 수 있었습니다.
이번 프로젝트는 LLM과 RAG를 활용한 검색 고도화가 단순한 API 호출이 아니라, 도메인 특성에 맞는 정교한 프로세스 설계의 영역임을 이해하게 되었습니다.
읽어주셔서 감사합니다!
Reference
- https://milvus.io/docs/ko/rrf-ranker.md
RRF 랭커 | Milvus 문서화
상호 순위 융합(RRF) 랭커는 밀버스 하이브리드 검색의 순위 재조정 전략으로, 원시 유사도 점수가 아닌 순위 순위에 따라 여러 벡터 검색 경로의 결과를 균형 있게 조정합니다. 개별 통계가 아닌
milvus.io
'프로그래머스 단기심화 데브코스' 카테고리의 다른 글
| 트랜잭션 아웃박스 패턴과 Consumer 멱등성 보장으로 Kafka 이벤트 일관성 보장하기 (0) | 2026.01.04 |
|---|---|
| Virtual Thread 도입을 통한 비동기 이벤트 처리 (0) | 2026.01.01 |
- Total
- Today
- Yesterday
- 무상태성
- LLM
- 디자인패턴
- 세션
- java
- jpa
- AttributeConverter
- kafka
- 외부API
- SpringBatch
- 싱글모듈
- 정기결제
- socket통신
- 항해99
- rrf
- 프로그래머스
- 네트워크
- http메서드
- virtual thread
- spring.
- TransactionalOutbox
- 분산락
- redisson
- 커넥션풀문제
- StringBuilder
- Http 버전
- leetcode
- 알고리즘
- 백준
- 벌크헤드
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |