목표(개요)
시스템의 검색 엔진을 개발하면서, 최근 실시간 인기 검색어 기능을 추가할 필요가 생겼다.
인기검색어란 단순히 많이 검색된 것을 넘어서, 얼마나 많이 최근에 검색되었는가?를 구현하는 것이 중요하다고 생각하였다.
그에 따라 아래와 같은 쿼리 기준을 작성하였다.
- searchTime기준으로 도큐먼트 1000개 추출
- 현재 밀리초와 searchTime 밀리초를 가지고 0~1 가중치(Decay) 추출
- 같은 keyword를 가진 document끼리 가중치 더하기
- 가중치를 기반으로 상위 10개의 keyword와 점수 반환
전체 쿼리
Kibana를 통한 Painless 스크립트로 작성하였으며, 아래는 전체 쿼리이다.
GET search_history/_search
{
"size": 0,
"aggs": {
"recent_popular_keywords": {
"scripted_metric": {
"init_script": """
// shard 단위로 처음 한번 실행
state.docs = [];
state.now = System.currentTimeMillis();
// 예: 1시간 단위 감쇠
state.scale = 3600000.0;
""",
"map_script": """
// shard 내 각 문서마다 실행
long docTime = doc['searchTime'].value.toInstant().toEpochMilli();
String kw = doc['keyword'].value;
// 문서 목록에 (시간, 키워드) 저장
state.docs.add(['time': docTime, 'keyword': kw]);
""",
"combine_script": """
// shard-level 결과를 반환
// (docs 배열 그대로)
return state.docs;
""",
"reduce_script": """
// 모든 shard의 partial 결과(리스트들)를 합치기
def allDocs = new ArrayList();
for (s in states) {
allDocs.addAll(s);
}
// now, scale은 이미 init_script에서 shard별로만 있지만
// reduce_script도 같은 값 사용하려면
// 아래처럼 다시 정의하거나, states 중 하나에서 가져올 수 있음
long now = System.currentTimeMillis();
double scale = 3600000.0;
allDocs.sort((a,b) -> {
long tA = (long)a.time;
long tB = (long)b.time;
// 내림차순: tB가 클 때(=더 최근) 음수가 되도록
if (tB > tA) return 1;
else if (tB < tA) return -1;
else return 0;
});
// 2) 상위 1000개만 남기기
if (allDocs.size() > 1000) {
allDocs = allDocs.subList(0,1000);
}
// 3) 키워드별 decay 합산
def keywordScores = [:]; // Map<String,Double>
for (doc in allDocs) {
long docTime = (long) doc.time;
String keyword = (String) doc.keyword;
long diff = now - docTime;
double decay = Math.exp(- diff / scale);
double oldVal = keywordScores.containsKey(keyword) ? keywordScores[keyword] : 0.0;
keywordScores[keyword] = oldVal + decay;
}
// 4) 키워드별 점수를 내림차순 정렬 후 상위 10개
def resultList = [];
for (entry in keywordScores.entrySet()) {
resultList.add(['keyword': entry.getKey(), 'score': entry.getValue()]);
}
resultList.sort((a,b) -> {
double vA = (double) a.score;
double vB = (double) b.score;
// 내림차순 (vB가 클수록(즉 b가 더 큼) -> b 앞으로)
if (vB > vA) {
return 1;
} else if (vB < vA) {
return -1;
} else {
return 0;
}
});
if (resultList.size() > 10) {
resultList = resultList.subList(0, 10);
}
// 최종 구조: topKeywords 배열을 담아서 반환
return [
'topKeywords': resultList
];
"""
}
}
}
}
부분 설명
아래는 쿼리의 핵심 부분이다.
for (doc in allDocs) {
long docTime = (long) doc.time;
String keyword = (String) doc.keyword;
long diff = now - docTime;
double decay = Math.exp(- diff / scale);
double oldVal = keywordScores.containsKey(keyword) ? keywordScores[keyword] : 0.0;
keywordScores[keyword] = oldVal + decay;
}
추출한 모든 document를 가지고 점수 계산 과정을 실행한다.
현재 시간 밀리초와 검색 시간 밀리초를 가지고 diff를 계산한다.
Math.exp 함수 설명
Math.exp(- diff / scale);
Math.exp 함수는 위 수식을 의미한다.
- diff는 현재 시간과 검색 시간의 차이 (diff는 최근 검색어 일수록 감소)
- scale는 감쇠 속도를 조절하는 시간을 의미 (scale이 크면 감쇠속도 감소, 작으면 감쇠속도 증가)
Decay 값에 따른 점수 변화
scale = 3,600,000 ms (1시간) 기준 점수 변화이다.
1시간을 기준으로 약 0.37 점수 값을 가지는 것을 확인할 수 있다.
그리고 그 시간이후 값이 급격하게 감소하는 것을 볼 수 있다.
이 표를 보고 알 수 있는 것은 scale을 조정하여, 요청 검색 트렌드에 따라 분포를 변경할 수 있다.!
결과
쿼리 실행 후 아래와 같은 결과를 받게 되었다.
{
"took" : 90,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 4,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"recent_popular_keywords" : {
"value" : {
"topKeywords" : [
{
"score" : 6.368996074622488E-8,
"keyword" : "타이레놀"
},
{
"score" : 6.346052500761374E-8,
"keyword" : "아스피린"
},
{
"score" : 1.4297731196382048E-8,
"keyword" : "소화제"
}
]
}
}
}
}
'Elasticsearch' 카테고리의 다른 글
[Elasticsearch] 도큐먼트 저장 방식과 작동 원리 정리 (0) | 2025.03.24 |
---|---|
[Elasticsearch 보안 설정] 비밀번호 인증 활성화 및 Spring, Kibana 연동 (0) | 2025.03.22 |
[Elasticsearch] Docker 환경에서 스냅샷(Snapshot) 백업 및 자동화 설정 (0) | 2025.03.21 |
[ElasticSearch] ElasticSearch Native Query 최신 버전 작성 (0) | 2025.03.04 |
[ElasticSearch] bool 타입 minimum_should_match 오류 (0) | 2025.03.04 |