Spring + Elasticsearch 전문 검색 인덱스 적용 - Elasticsearch 익히기 (1)
GitHub - lsh2613/Elasticserach: Spring + Elasticserach를 적용한 전문 검색 인덱스 성능 비교
Spring + Elasticserach를 적용한 전문 검색 인덱스 성능 비교. Contribute to lsh2613/Elasticserach development by creating an account on GitHub.
github.com
1. Elasticsearch란?
ELK, Elastic Stack이라 불리는 Elasticsearch, Logstash, Kibana 중 ELK의 심장이라 불리는 Elasticsearch는 뛰어난 검색 능력과 대규모 분산 시스템을 구축할 수 있는 다양한 기능을 제공한다.
DBMS에서 사용하는 용어와 혼동되기 쉬워 아래 표를 참고하도록 하자
1.1. Elasticsearch 특징
1. 실시간 분석(real-time)
현재 대용량 데이터 분석에 가장 널리 사용되고 있는 하둡(Hadoop)은 기본적으로 분석에 사용될 소스 데이터와 분석을 수행할 프로그램을 올려 놓고 분석하여 결과 set이 나오도록 하는 루틴으로 실행된다.
하지만 Elasticsearch는 클러스터가 실행되고 있는 동안 계속해서 데이터가 입력되고, 그와 동시에 실시간에 가까운 속도로 색인된 데이터의 점색, 집계가 가능하다
2. 전문(full text) 검색 엔진
루씬을 사용하고 있는 Elasticsearch도 마찬가지로 색인된 모든 데이터를 역파일 색인 구조로 저장하여 가공된 텍스트를 검색한다. 이런 특성을 전문(full text) 검색이라 한다
루씬이란?
자바로 만들어진 고성능 정보 검색 기능 라이브러리로 역파일 색인 구조로 데이터를 저장한다
MySQL에서도 전문 검색 인덱스를 제공하지만 여러 이유로 잘 사용하지 않는 것 같다.
Elasticsearch에서는 데이터를 역 인덱스로 저장하여 역파일 색인 구조를 만든다.
RDBMS에서 like 검색을 사용하여 레코드를 찾을 때 데이터가 늘어나면 검색해야 될 대상이 늘어나 오버헤드가 크다.
하지만 오른쪽과 같이 역 인덱스 구조로 만들어 관리하면 효율적인 검색이 가능하다
3. RESTFul API
모든 데이터 조회, 입력, 삭제를 http 프로토콜을 통해 Rest API로 처리가 가능하다
4. 멀티테넌시 (multitenancy)
Elasticsearch의 데이터들은 인덱스라는 논리적인 집합 단위로 구성되며 서로 다른 저장소에 분산되어 저장된다.
서로 다른 인덱스들을 별도의 커넥션 없이 하나의 질의로 묶어서 검색하고, 검색 결과들을 하나의 출력으로 도출할 수 있다.
이러한 특징을 멀티테넌시라고 한다.
1.2 Elasticsearch의 텍스트 분석
Elasticsearch는 문자열 필드가 저장될 때 데이터에서 검색어 토큰을 저장하기 위해 여러 단계의 처리 과정을 거친다.
이 전체 과정을 텍스트 분석(Text Analysis)라 하고 이 과정을 애널라이저(Analyzer)가 처리한다
첨언하자면 애널라이저 = 캐릭터 필터(필수) + 토크나이저(필수) + 토큰 필터(옵션) 이다.
- 캐릭터 필터는 전체 문장에서 특정 문자를 대체하거나 제거하는 역할
- 토크나이저는 문장 속 단어들을 텀(term) 단위로 분리하는 역할로 반드시 하나만 적용
- 토큰 필터는 분리된 텀(term)들을 하나씩 가공하는 역할
- ex) 텀의 포함된 대문자를 소문자로 바꿔서 저장하기
2. 노리(Nori) 한글 형태소 분석기
Elasticsearch 6.6부터 Elastic사에서 공식적으로 개발하여 지원한 한글 형태소 분석기가 Nori 분석기이다.
Elasticsearch에서 기본적인 분석기를 제공하지만 Nori 분석기를 사용하면 nori-tokenizer 옵션을 통해 어근만 or 어근과 합성어 모두를 저장할 수도 있다. 또 다른 기능으로는 품사 제거, 한자->한글 변환, 텍스트<->숫자 변환 등 다양한 기능을 제공한다
nori_tokenizer의 자세한 기능은 Elastic-nori-docs 에서 확인 가능하다
내가 사용해볼 기능은 nori_tokenizer의 decompound_mode이다. decompound_mode를 통해 어근과 합성어를 어떻게 처리할지 설정할 수 있다.
"서울역"
어근: '서울', '역'
합성어: '서울역'
decompound_mode = none
- 합성어만 저장
- '서울역'
decompound_mode = discard(default)
- 어근만 저장
- '서울', '역'
decompound_mode = mixed
- 어근 + 합성어 저장
- '서울', '역', '서울역'
3. Spring + Elasticsearch 연동
nori 분석기를 사용하기 위해 docker-compose build 시 Elasticsearch에 `analysis-nori` 플러그인을 설치하도록 구현하였다.
추가로 Elasticsearch를 활용할 데이터들을 시각화하여 확인하기 위해 Kibana도 같이 연동하였다
docker-compose.yml
version: '3.8'
services:
es:
build:
context: .
dockerfile: Dockerfile.es
image: docker.elastic.co/elasticsearch/elasticsearch:8.7.1
container_name: es
environment:
- node.name=es-node
- cluster.name=search-cluster
- discovery.type=single-node
- bootstrap.memory_lock=true
- ES_JAVA_OPTS=-Xms1g -Xmx1g
- xpack.security.enabled=false
- xpack.security.http.ssl.enabled=false
- xpack.security.transport.ssl.enabled=false
ports:
- 9200:9200
networks:
- es-bridge
kibana:
image: docker.elastic.co/kibana/kibana:8.7.1
container_name: kibana
environment:
SERVER_NAME: kibana
ELASTICSEARCH_HOSTS: http://es:9200
ports:
- 5601:5601
depends_on:
- es
networks:
- es-bridge
networks:
es-bridge:
driver: bridge
- xpack.security.enabled=false
- xpack.security.http.ssl.enabled=false
- xpack.security.transport.ssl.enabled=false
위 설정을 통해 보안을 비활성화하여 최대한 간단히 연동 및 테스트를 진행하였다
Dockerfile.es
FROM docker.elastic.co/elasticsearch/elasticsearch:8.7.1
RUN if ! elasticsearch-plugin list | grep -q analysis-nori; then \
elasticsearch-plugin install analysis-nori; \
fi
docker compose를 재실행 하더라도 nori 플러그인이 없는 경우에만 플러그인을 설치
이제 docker-compose up -d --build를 활용하여 es, kibana를 띄우면 다음과 같은 url에 접근할 수 있다
localhost:5601로 접속하여 Devtools를 검색하면 Console을 통해 ES에 쉽게 쿼리를 보낼 수 있다
4. Elasticsearch 사용법
Spring에 적용하기 전, Elasticsearch 사용하는 법을 먼저 익혀보았다
Elasticsearch 명령어 및 문법은 Elasticsearch 가이드 북에 잘 설명되어 있다.
4.1. Index 생성 - Tokenizer 설정
PUT /my_nori
{
"settings": {
"analysis": {
"tokenizer": {
"nori_none": {
"type": "nori_tokenizer",
"decompound_mode": "none"
},
"nori_discard": {
"type": "nori_tokenizer",
"decompound_mode": "discard"
},
"nori_mixed": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
}
}
}
}
}
my_nori 이름의 인덱스 생성
my_nori 인덱스 토크나이저 설정으로 nori_none, nori_discard, nori_mixed 선언
4.2. 텍스트 분석
저장된 인덱스를 통해 텍스트를 분석
POST /my_nori/_analyze
{
"tokenizer": "nori_none",
"text": "서울역"
}
# '서울역'
POST /my_nori/_analyze
{
"tokenizer": "nori_discard",
"text": "서울역"
}
# '서울', '역'
POST /my_nori/_analyze
{
"tokenizer": "nori_mixed",
"text": "서울역"
}
# '서울', '역', '서울역'
4.3. Index 생성 - Analyzer 설정 & 매핑
PUT /post
{
"settings": {
"analysis": {
"tokenizer": {
"nori_mixed": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
}
},
"analyzer": {
"nori_korean": {
"type": "custom",
"tokenizer": "nori_mixed"
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "nori_korean"
},
"content": {
"type": "text",
"analyzer": "nori_korean"
}
}
}
}
decompound_mode=mixed인 nori_tokenizer를 가지는 nori_mixed 토크나이저 설정
nori_mixed를 가지는 nori_korean 분석기 설정
nori_korean 분석기를 적용한 title, content 속성을 가지는 post 인덱스 생성
4.4. 다큐먼트 추가
POST /post/_doc
{
"title": "애국가",
"content": "동해물과 백두산이"
}
# index에는 기본적으로 id가 존재한다.
# id를 지정해서 생성하고 싶으면
POST /post/_doc/{id}
{
...
}
4.5. 분석기가 적용된 term 조회
# 문법
GET /{인덱스명}/_termvectors/{id}?fields={조회할 속성}
GET /post/_termvectors/1_kYXpMBRN7vPJbAIl0T?fields=title,content
결과
{
"_index": "post",
"_id": "1_kYXpMBRN7vPJbAIl0T",
...
"term_vectors": {
"title": {
...
"terms": {
"가": {
...
},
"애국": {
...
},
"애국가": {
...
}
}
},
"content": {
...
"terms": {
"과": {
...
},
"동해": {
...
},
"물": {
...
},
"백두": {
...
]
},
"백두산": {
...
},
"산": {
...
},
"이": {
...
]
}
}
}
}
}
앞서 살펴본 대로 decompund_mode=mixed가 적용된 nori_tokenzier로 인해 어근, 합성어가 모두 저장되어 있는 것을 확인해볼 수 있다
4.6. term을 통한 인덱스 검색
결국 하고자 했던 것은 내가 만든 역인덱스를 통해 term만 검색했을 때 어떤 index에 해당하는 지를 알아내는 것이다.
주의할 점은 match 안에 또 query를 통해 옵션을 추가할 수 있다. 추가할 수 있는 옵션은 여기서 자세히 확인할 수 있다.
# 문법 1
GET /{인덱스명}/_search
{
"query": {
"match": {
"{속성명}": "{keyword}"
}
}
}
# 문법 2
GET /{인덱스명}/_search
{
"query": {
"match": {
"query": {
"{속성명}": "{keyword}",
"{추가옵션1}": {value},
"{추가옵션1}": {value},
...
}
}
}
}
GET /post/_search
{
"query": {
"match": {
"content": "동해"
}
}
}