ElasticSearch
- Apache Lucene에 구축되어 배포된 검색 및 분석 엔진
- 오픈 소스
- 실시간 분석 시스템
- 전문 검색 엔진(Full Text Search)
- RESTFul API 지원
- 멀티테넌시 지원
도입 배경
- STT로 받은 음식 이름에 대한 오차를 줄이고 일반적인 유통기한 추출 가능
- 역색인을 통한 빠른 검색 가능
동작 원리
- 엘라스틱 서치에 index라고 부르는 DB에 문서 저장
- 메타데이터(문서의 내부 식별자, 버전 등)와 소스데이터(문자열)를 key-value 형태의 JSON으로 변형하여 저장 ⇒ document
- 역색인 자료구조로 저장되어 검색이 빠름
- 유의어, 동의어, 전문 검색을 하기 위해 Json 문서 형식의 데이터를 Logstash 및 Amazon Kinesis Data Firehose 와 같은 수집 도구 나 API를 사용해 전송
- 분석기를 통해 데이터 분석
- 문서별로 의미있는 데이터만 추출
- 특정 검색어에 대해 유사도 점수 부여 → 검색한 것과 가장 가까운 문서 찾음
텍스트 분석
캐릭터 필터
- 텍스트 분석 중 가장 먼저 처리되는 과정
- 토크나이저에 의해 분리되기 전에 전체 문장에 적용되는 필터
- 종류
- 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: ‘냉장고’, ‘에서’, ‘신선도’, ‘를’, ‘분석’, ‘하는’, ‘사이트’
- ex) 냉장고에서 신선도를 분석하는 사이트
인덱스 생성
초기 인덱스 생성기
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 : 사용자 사전으로 특정 단어에 가중치 부여 가능
- decompound_mode : 합성어 저장 방식 결정
- nori_tokenizer : 한글 형태소 분석기
- filter : 토큰 필터 지정
- nori_part_of_speech : 제거할 품사 정보 지정 가능
- char_filter : 캐릭터 필터 지정
- analyzer: 애너라이저 지정
- tokenizer: 토크나이저 지정
- 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 |
|---|