Elasticsearch MLT로 유사도 기반 추천 기능 구현하기

dev.hyjang·2025년 9월 4일

오늘은 Elasticsearch의 내장 기능 중 More Like This (MLT) 쿼리를 활용하여 유사도가 높은 데이터를 조회하는 기능을 구현해보겠습니다. "유사도"는 문서에 포함된 단어들의 출현 빈도(Term Frequency) 등을 복합적으로 계산하여 결정됩니다. 이 MLT 기능을 활용하여, 사용자가 특정 뉴스 기사를 보고 있을 때 그와 내용이 비슷한 다른 뉴스 기사들을 추천해 주는 기능을 구현해 보겠습니다.


Elasticsearch 'More Like This'

MLT 쿼리란 ?

우리는 웹, 앱 어디서든 검색 결과와 관련된 '추천' 콘텐츠를 쉽게 마주합니다. 아래 이미지와 같이 벨로그에서도 포스팅 하단에 '관심 있을 만한 포스트'를 제공합니다. Elasticsearch의 MLT 쿼리는 바로 이러한 추천 기술을 가능하게 합니다.

MLT 쿼리는 말 그대로 "이것과 비슷한" 문서를 찾아주는 쿼리입니다. 사용자가 특정한 문서(또는 텍스트)를 선택하면 그것와 내용이 유사한 다른 것들을 추천해주는 기능을 합니다.

MTL 쿼리 방식은 아주 간단하게 아래와 같이 동작합니다.

  • "엘라스틱서치 MLT를 활용한 검색 시스템"이라는 뉴스 기사를 기준 문서로 정함
  • 엘라스틱서치가 이 문서에서 중요한 단어(키워드)를 뽑음
  • 그 키워드들을 기준으로 비슷한 단어가 많이 들어있는 다른 문서를 찾아줌

또한 아래와 같이 쿼리를 작성하여 검색 내용을 설정할 수 있습니다.

GET news/_search
{
  "query": {
    "more_like_this": {	                 // MLT 쿼리를 쓰겠다
      "fields": ["title", "content"],    // 어떤 필드에서 비교할지(제목과 콘텐츠)
      "like": [                          // 이 문서와 비슷한 걸 찾아라
        {
          "_index": "news",              // news로 인덱싱 되어 있고,
          "_id": "1"                     // id가 1인 문서
        }
      ],
      "min_term_freq": 1,              // 최소 몇 번은 나와야 키워드로 쓸지(1번 이상)
      "min_doc_freq": 1                // 최소 몇 개 문서에 등장해야 쓸지(1번 이상)
    }
  }
}
  • 검색어가 들어오면 어떤 필드와 비교할지를 결정
  • 어떤 문서를 대상으로 할지 결정
  • 그 문서에서 몇 번 이상 언급되어야 키워드로 취급할지 결정
  • 다른 문서 중 몇 번 이상 키워드가 언급되어야 관련 문서로 취급하는지 결정

해당 쿼리는 아주 기본적인 MLT 쿼리 작성 방식이며, 아래의 공식 문서 참고하여 추가적으로 검색 설정을 고급화할 수 있습니다.

MLT 쿼리 장점

  • 강력한 유사도 분석: 단순히 단어의 빈도를 넘어, BM25/TF-IDF 알고리즘을 기반으로 문서(뉴스) 간의 연관성을 지능적으로 분석합니다.
    • 엘라스틱서치 5.x 버전 이후 TF-IDF → BM25 알고리즘으로 변경(TF-IDF를 개선한 가중치 모델)
  • 간결한 구현: 복잡한 유사도 계산 로직을 직접 구현할 필요 없이, 간단한 쿼리 하나로 해결할 수 있습니다.
  • 유연한 설정: 최소 단어 빈도, 최소 문서 빈도 등 다양한 파라미터를 조정하여 추천의 정밀도를 튜닝할 수 있습니다.

3. 구현

진행하고 있는 프로젝트에 이미 검색 엔진으로 사용 중인 Elasticsearch 의 내장 기능 More Like This 쿼리를 활영하여 뉴스 목록 검색 시 관련 뉴스도 함께 검색되도록 구현해보겠습니다.

1. '유사 뉴스 검색' 서비스 메소드 작성

  • NewsService 인터페이스에 searchSimilarNews(Long newsId) 메소드를 선언합니다.
  • NewsServiceImpl에 moreLikeThisQuery를 사용하여 실제 추천 로직을 구현합니다.
  • 이 쿼리는 Elasticsearch에게 "이 newsId를 가진 문서와 title, description 필드의 내용이 유사한 다른 문서들을 찾아줘" 라고 요청하는 역할을 부여합니다.
  • 반환되는 데이터에 Redis의 정보를 결합(추가 정보 최신화 ex. 좋아요 수, 사용자 정보 등)하여 NewsResponseDto 형태로 제공합니다.
    @Override
    public List<NewsResponseDto> searchSimilarNews(Long newsId) {
        Query esQuery = new Query.Builder()
                .moreLikeThis(mlt -> mlt
                        .fields("title", "description")
                        .like(l -> l.document(d -> d.index("news").id(String.valueOf(newsId))))
                        .minTermFreq(1)
                        .minDocFreq(1)
                )
                .build();
        return searchElasticsearch(esQuery);
    }

	//엘라스틱서치 검색
    private List<NewsResponseDto> searchElasticsearch(Query esQuery) {
        NativeQuery nativeQuery = new NativeQueryBuilder().withQuery(esQuery).build();
        SearchHits<NewsDocument> searchHits = elasticsearchOperations.search(nativeQuery, NewsDocument.class);

        List<Long> newsIds = new ArrayList<>();
        //searchHits에서 newsIds를 반환
        for(SearchHit<NewsDocument> hit : searchHits.getSearchHits()) {
            NewsDocument content = hit.getContent();
            newsIds.add(content.getNewsId());
        }
        return orderResults(newsIds);
    }

    // 결과 유사도 정렬 유지
    private List<NewsResponseDto> orderResults(List<Long> newsIds) {
        if (newsIds.isEmpty()) {
            return Collections.emptyList();
        }

        Map<Long, NewsResponseDto> newsDtoMap = new HashMap<>();
        List<NewsResponseDto> newsByIds = newsMapper.findNewsByIds(newsIds);

        for (NewsResponseDto newsDto : newsByIds) {
            newsDtoMap.put(newsDto.getNewsId(), newsDto);
        }

        List<NewsResponseDto> newsList = (List<NewsResponseDto>) newsDtoMap.values();
        String userId = checkUserId();
        matchEsResultsWithRedisData(newsList, userId);

        List<NewsResponseDto> resultList = new ArrayList<>();
        //검색결과 유사도 정렬 유지
        for (Long newsId : newsIds) {
            NewsResponseDto newsDto = newsDtoMap.get(newsId);
            if (newsDto != null) {
                resultList.add(newsDto);
            }
        }
        return resultList;
    }
  1. moreLikeThisQuery를 사용하여 Elasticsearch에 요청할 쿼리를 생성합니다.
  2. 아래의 검색 정보를 설정합니다.
    • 어떤 필드를 비교할 것인가: title과 description 필드의 내용을 비교하도록 지정
    • 어떤 문서를 기준으로 할 것인가: like() 메소드에 기준이 되는 뉴스의 ID(newsId)를 전달
    • 최소 빈도 설정: minTermFreq(1), minDocFreq(1) 옵션 설정(너무 드물게 나타나는 단어는 유사도 계산에서 제외)
  3. searchNews 메소드에서 구현했던 로직을 재사용하여, Elasticsearch에서 찾은 유사 뉴스 ID 목록에 DB와 Redis의 최신 상태 정보를 결합하여 최종 결과를 반환합니다.

2. 컨트롤러 계층에 유사 검색 API 엔드 포인트 추가

	// 유사 뉴스 추천
    @GetMapping("/news/{newsId}/similar")
    public List<NewsResponseDto> findSimilarNews(@PathVariable Long newsId) {
        return newsService.findSimilarNews(newsId);
    }
  • NewsController에 GET /api/news/{newsId}/similar 라는 API 엔드포인트를 만듭니다.
  • 이 엔드포인트는 경로의 newsId를 기준으로, 해당 뉴스와 유사한 뉴스 목록을 클라이언트에게 반환합니다.

테스트 및 화면단 코드 추가

위과 같이 백엔드 코드를 추가 후 화면에서 '관련 뉴스'가 표출될 수 있도록 vue.js 코드를 수정 및 추가하여 테스트를 진행하였습니다.

  • 기본 엘라스틱서치 검색 후 반환되는 뉴스 중 첫 번째 뉴스(가장 관련도가 높음)를 기준으로
  • 유사 뉴스 검색 API 전송
  • 일반 뉴스 검색 목록 + 유사 뉴스 목록 구분하여 표출
const news = ref([])
const similarNews = ref([]);
const searchQuery = ref("")

const searchNews = async () => {
    const accessToken = userStore.token;
    if (!accessToken) {
        alert("로그인이 필요합니다.");
        router.push('/');
        return;
    }

    if (!searchQuery.value.trim()) {
        fetchNews();
        similarNews.value = [];
        return;
    }

    //뉴스 초기화
    news.value = [];
    similarNews.value = [];

    try {
        // 뉴스 검색
        const response = await apiRequest(`/api/news/search?q=${encodeURIComponent(searchQuery.value)}`, {
            method: 'GET',
            headers: { Authorization: `Bearer ${accessToken}` },
        });

        if (response.status === 401 || response.status === 403) {
            alert("인증이 필요합니다. 로그인 페이지로 이동합니다.");
            router.push('/');
            return;
        }

        if (!response.ok) {
            throw new Error('뉴스 검색 API 호출 실패');
        }

        const searchData = await response.json();
        news.value = searchData.map(item => ({
            ...item,
            likeCount: item.likeCount || 0,
            isLiked: item.liked || false,
        }));
        
        // 유사 뉴스 검색
        if (news.value.length > 0) {
            const firstNewsId = news.value[0].newsId; // 첫 번째 뉴스 ID 추출

            const similarNewsResponse = await apiRequest(`/api/news/${firstNewsId}/similar`, {
                method: 'GET',
                headers: { Authorization: `Bearer ${accessToken}` },
            });

            if (similarNewsResponse.ok) {
                const similarNewsData = await similarNewsResponse.json();
                similarNews.value = similarNewsData.map(item => ({
                    ...item,
                    likeCount: item.likeCount || 0,
                    isLiked: item.liked || false,
                }));
            } else {
                console.error('유사 뉴스 API 호출 실패');
                similarNews.value = [];
            }
        }

    } catch (err) {
        console.error('오류 발생:', err);
        alert(err.message || '뉴스 검색 중 오류가 발생했습니다.');
        news.value = [];
        similarNews.value = [];
    } 
};

  • 검색 결과뿐만 아니라 '관련 뉴스'도 표출이 되는 것을 확인할 수 있었습니다.
  • Redis의 최신 상태 정보를 결합 결과가 표출되는 것을 확인할 수 있었습니다.

결론

Elasticsearch의 More Like This 쿼리를 활용해 복잡한 추천 알고리즘 없이도 간단하게 유사 뉴스 추천 기능을 구현할 수 있었습니다. 처음 접했을 때는 다소 낯설고 어렵게 느껴졌지만, 엘라스틱서치가 제공하는 간단한 설정만으로 서비스에 꼭 필요한 ‘추천’ 기능을 손쉽게 구현할 수 있음을 확인했습니다.

오늘 구현한 작업을 바탕으로 추천 결과에 사용자 행동 데이터(예: 조회수, 좋아요, 클릭 이력)를 반영하거나, 가중치를 조정하는 방식으로 기능을 고도해볼 계획입니다.

profile
낭만감자

0개의 댓글