[SpringBoot] ElasticSearch 연동

ElasticSearch

  • Apache Lucene에 구축되어 배포된 검색 및 분석 엔진
  • 오픈 소스
  • 실시간 분석 시스템
  • 전문 검색 엔진(Full Text Search)
  • RESTFul API 지원
  • 멀티테넌시 지원

도입 배경

  • STT로 받은 음식 이름에 대한 오차를 줄이고 일반적인 유통기한 추출 가능
  • 역색인을 통한 빠른 검색 가능

동작 원리

  1. 엘라스틱 서치에 index라고 부르는 DB에 문서 저장
    • 메타데이터(문서의 내부 식별자, 버전 등)와 소스데이터(문자열)를 key-value 형태의 JSON으로 변형하여 저장 ⇒ document
    • 역색인 자료구조로 저장되어 검색이 빠름
  2. 유의어, 동의어, 전문 검색을 하기 위해 Json 문서 형식의 데이터를 Logstash 및 Amazon Kinesis Data Firehose 와 같은 수집 도구 나 API를 사용해 전송
  3. 분석기를 통해 데이터 분석
    • 문서별로 의미있는 데이터만 추출
    • 특정 검색어에 대해 유사도 점수 부여 → 검색한 것과 가장 가까운 문서 찾음

텍스트 분석

캐릭터 필터

  • 텍스트 분석 중 가장 먼저 처리되는 과정
  • 토크나이저에 의해 분리되기 전에 전체 문장에 적용되는 필터
  • 종류
    • HTML Strip: HTML 태그 들을 제거하여 일반 텍스트로 변환
    • Mapping: 지정한 단어를 다른 단어로 치환
    • Pattern Replacie: 정규식을 이용해 더 복잡한 패턴들을 치환

토크나이저

  • 문자를 특정 단위로 잘라 각 토큰인 텀으로 만들어주는 도구
  • 반드시 한 개만 사용 가능
  • 종류
    • Standard: 공백으로 텀을 구분
    • Letter: 알파벳을 제외한 모든 공백, 숫자, 기호들을 기준으로 텀을 구분
    • Whitespace: 스페이스, 탭, 줄바꿈 같은 공백만을 기준으로 텀을 구분
    • UAX URL Email: 이메일 주소와 웹 URL 경로를 분리하지 않음
    • Pattern: 분리할 패턴을 기호 또는 Java 정규식 형태로 지정
    • Path Hierarchy: 경로 데이터를 계층별로 저장

토큰 필터

  • 토크나이저에 의해 분할된 각각의 텀들을 지정한 규칙에 따라 처리해주는 도구
  • 종류
    • Lowercase, Uppercase: 소문자 -> 대문자, 대문자 -> 소문자
    • Stop: 불용어에 해당되는 텀 제거
    • Synonym: 텀의 동의어 저장 가능
    • Ngram: 최소, 최대 문자수의 토큰을 구분
    • Edge NGram: 텀 앞쪽의 Ngram만 저장
    • Shingle: 단어 단위로 토큰 구분
    • unique: 중복되는 텀 제거

형태소 분석

  • 각각의 텀에 있는 단어들을 기본 형태인 어간을 추출하는 과정
  • 종류
    • Snowball: ~ing, ~s 등을 제거하여 문장에 쓰인 단어들을 기본 형태로 변경
    • Nori 한글 형태소 분석기: Elastic 사에서 공식적으로 개발

Analyzer

  • _analyze API: tokenizer, filter 항목을 통해 text를 분석 가능
  • term 쿼리: 애널라이저를 적용하지 않고 입력된 검색어 그대로 일치하는 텀을 찾음
  • Custom Analyzer: 토크나이저, 토큰 필터 등을 조합하여 만든 사용자 정의 애널라이저

ELK 설치

  • git clone
git clone https://github.com/deviantony/docker-elk.git
  • .env 파일 수정
ELASTIC_VERSION=8.12.2

ELASTIC_PASSWORD='{elastic search 비밀번호}'

LOGSTASH_INTERNAL_PASSWORD='{}'
KIBANA_SYSTEM_PASSWORD='{}'

METRICBEAT_INTERNAL_PASSWORD=''
FILEBEAT_INTERNAL_PASSWORD=''
HEARTBEAT_INTERNAL_PASSWORD=''

MONITORING_INTERNAL_PASSWORD=''

BEATS_SYSTEM_PASSWORD=''
  • docker-compose.yml을 커스텀한 후 올림
docker-compose up -d

 

nori 설치

  • elastic search에서 한글 형태소 분석기 중 가장 널리 사용되는 것
  • 카카오에서 개발한 형태소 분석기로, elastic search에서 공식 지원
  • elasticsearch에 접속한 후 아래와 같은 명령어를 사용해서 analysis-nori 설치
./bin/elasticsearch-plugin install analysis-nori
  • standard vs nori
    • ex) 냉장고에서 신선도를 분석하는 사이트
      • standard: ‘냉장고에서’, ‘신선도를’, ‘분석하는’, ‘사이트’
      • nori: ‘냉장고’, ‘에서’, ‘신선도’, ‘를’, ‘분석’, ‘하는’, ‘사이트’

인덱스 생성

초기 인덱스 생성기

PUT /befresh
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "tokenizer": {
        "nori_mixed": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed",
          "discard_punctuation": "false"
        }
      },
      "filter": {
        "stopwords" : {
          "type":"stop",
          "stopwords": " "
        },
        "my_pos_f" : {
          "type" : "nori_part_of_speech",
          "stoptags" : [
            "E", "IC", "J", "MAG", "MAJ",
            "MM", "SP", "SSC", "SSO", "SC",
            "SE", "XPN", "XSA", "XSN", "XSV",
            "UNA", "NA", "VSV"
          ]
        }
      }, 
      "analyzer": {
        "name_analyzer": {
          "type": "custom",
          "tokenizer": "nori_mixed",
          "filter": ["my_pos_f", "stopwords"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "category": {
        "type": "keyword"
      },
      "name": {
        "type": "text",
        "analyzer": "name_analyzer"
      },
      "expiration_date": {
        "type": "integer",
        "index" : "false"
      }
    }
  }
}
  • settings
    • number_of_shards : 인덱스 처음 생성 시 샤드 수 지정, 변경 불가, default 1
    • number_of_replicas : 변경 가능, default 1
    • analysis : 변경 불가, 텍스트 분석 설정
      • tokenizer: 토크나이저 지정
        • nori_tokenizer : 한글 형태소 분석기
          • decompound_mode : 합성어 저장 방식 결정
            • none: 완성된 합성어만
            • discard: 합성어를 분리하여 각 어근만
            • mixed: 어근과 합성어 모두 저장
          • user_dictionary_rules : 사용자 사전으로 특정 단어에 가중치 부여 가능
      • filter : 토큰 필터 지정
        • nori_part_of_speech : 제거할 품사 정보 지정 가능
      • char_filter : 캐릭터 필터 지정
      • analyzer: 애너라이저 지정
  • mappings : 필드 정의
    • type : 필드의 유형
    • analyzer : 필드에 분석기를 지정해서 사용할 수 있음

=> name 필드에 Custom 분석기(nori 형태소 분석기, stopwords/nori_part_of_speech 필터) 적용

 

✅ 문제점: 일부 단어에 오타가 있을 경우 검색이 안됨

 

=> Ngram 적용

  • 적용할 대상이 주로 단어 위주 검색이고 단어의 길이가 길지 않기 때문에 1~2로 토큰 분리 설정
  • Ngram과 Nori 분석기를 넣고 멀티 토크나이저를 이용해 인덱싱 설정
    • fields : 여러 개의 토크나이저 연결
    • 기본적으로는 standard
PUT /befresh
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "tokenizer": {
        "nori_mixed": {
          "type": "nori_tokenizer",
          "decompound_mode": "mixed",
          "discard_punctuation": "false"
        },
        "ngram_tokenizer": {
          "type": "ngram",
          "min_gram": "1",
          "max_gram": "2"
        }
      },
      "filter": {
        "stopwords" : {
          "type":"stop",
          "stopwords": " "
        }
      }, 
      "analyzer": {
         "name_analyzer": {
            "filter": [
	             "trim",
	             "stopwords"
	          ],
            "char_filter": [
              "html_strip"
            ],
            "type": "custom",
            "tokenizer": "nori_mixed"
          },
          "ngram_analyzer": {
            "filter": [
              "trim",
              "stopwords"
            ],
            "char_filter": [
              "html_strip"
            ],
            "type": "custom",
            "tokenizer": "ngram_tokenizer"
          }
       }
    }
  },
  "mappings": {
    "properties": {
      "category": {
        "type": "keyword"
      },
      "name": {
        "type": "text",
        "fields": {
            "ngram": {
              "type": "text",
              "analyzer": "ngram_analyzer"
            },
            "nori": {
              "type": "text",
              "analyzer": "name_analyzer"
            }
          }
      },
      "expiration_date": {
        "type": "integer",
        "index" : "false"
      }
    }
  }
}

 

Analyze

GET befresh/_analyze
{
  "analyzer" : "name_analyzer",
  "text" : "삶은 겨란"
}

 

 

여러 개의 데이터 삽입

POST /befresh/_bulk

{"index":{"_id":"100"}}
{"category": "고기", "name": "고기", "expiration_date": 3}
{"index":{"_id":"101"}}
{"category": "고기", "name": "소고기", "expiration_date": 5}
{"index":{"_id":"102"}}
{"category": "고기", "name": "돼지고기", "expiration_date": 2}

 

Elastic Search 조회

GET befresh/_search
{
  "query": {
      "bool": {
        "should": [
          {
            "match": {
              "name": {
                "query": "삶은 겨란",
                "boost": 3
              }
            }
          },
          {
            "match": {
              "name.nori": {
                "query": "삶은 겨란",
                "boost" : 2
              }
            }
          },{
            "match": {
              "name.ngram": {
                "query": "삶은 겨란",
                "boost" : 1
              }
            }
          }
        ]
     }
  }
}

=> 해당 조회 문을 Spring Boot에서 구현

Spring Boot + Elastic Search

STT로 음식을 등록하는 과정에서, 음식 이름의 오차를 줄이기 위해 Elastic Search 검색 엔진을 구현
  • 버전: Spring boot 3.2 + elastic search 8버전 이상
  • 버전에 따라 지원되는 것이 달라서 공식 문서를 기반으로 찾아서 구현

elastic search 설정

  • build.gradle
	//elastic search
	implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
  • application.yml
spring:
  elasticsearch:
    username: {}
    password: {}
    uris: {url:port}
  • ElasticsearchClientConfig.java
package com.a307.befresh.global.config.elastic;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.client.ClientConfiguration;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration;

@Configuration
public class ElasticsearchClientConfig extends ElasticsearchConfiguration {

    @Value("${spring.elasticsearch.username}")
    private String username;

    @Value("${spring.elasticsearch.password}")
    private String password;

    @Value("${spring.elasticsearch.uris}")
    private String uris;

    @Override
    public ClientConfiguration clientConfiguration() {
        return ClientConfiguration.builder()
            .connectedTo(uris)
            .withBasicAuth(username, password)
            .build();
    }

}

 

Document

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "befresh")
public class ElasticDocument {

    @Id
    private String id;

    @Field(type= FieldType.Keyword, name = "category")
    private String category;

    @Field(type = FieldType.Text, name = "name")
    private String name;

    @Field(type=FieldType.Integer, name ="expiration_date")
    private int expirationDate;

    private double score; // score을 등록하기 위해 추가
}
  • type : 필드 유형
  • name : elastic search 필드명

Repository

  • 기본 JPA
public interface ElasticRepository extends ElasticsearchRepository<ElasticDocument, String> { }
  • Custom JPA
    • 위의 elastic search 조회문 구현
    @RequiredArgsConstructor
    @Repository
    public class ElasticRepositoryImpl implements ElasticCustomRepository {
    
        private final ElasticsearchOperations operations;
    
        @Override
        public List<ElasticDocument> searchBefreshByName(String name) {
    
            Query stand = MatchQuery.of(t -> t.field("name").query(name).boost(2f))._toQuery();
            Query nori = MatchQuery.of(t -> t.field("name.nori").query(name))._toQuery();
            Query ngram = MatchQuery.of(t -> t.field("name.ngram").query(name))._toQuery();
    
            NativeQuery query = NativeQuery.builder()
                .withQuery(Query.of(qb ->
                    qb.bool(b -> b
                        .should(stand)
                        .should(nori)
                        .should(ngram)
                    )))
                .build();
    
            SearchHits<ElasticDocument> searchHits = operations.search(query, ElasticDocument.class);
            List<ElasticDocument> resultList = new ArrayList<>();
    
            for (SearchHit<ElasticDocument> hit : searchHits) {
                if (hit.getScore() <= 4) {
                    continue;
                }
    
                ElasticDocument document = hit.getContent();
                document.setScore(hit.getScore());
                resultList.add(document);
            }
    
            return resultList;
        }
    
    }
    
    
    • standard, ngram, nori를 적용한 값들의 score을 합산해서 결과 출력
    • score가 일정 값 보다 낮으면 무의미한 결과라 판단

'BackEnd > SpringBoot' 카테고리의 다른 글

[Spring Boot] Virtual Thread 적용  (0) 2024.06.06