1. 개요
검색 기능을 붙였는데 "백두산"으로 검색하면 나오던 글이 "백두산이"라고 치면 안 나온다는 제보를 운영하다 보면 한 번쯤 받는다. 대부분 기본 토크나이저를 그대로 쓴 인덱스가 원인이다. 예전에 로그 검색용으로 대충 띄운 ELK를 그대로 서비스 검색에 붙였다가 이 문제로 다시 인덱스를 만든 적이 있다.
결론부터 말하자면, 한글 검색은 analysis-nori 플러그인을 설치하고 nori analyzer를 인덱스에 지정하면 대부분 해결된다. Nori는 한국어를 형태소 단위로 쪼개는 분석기라서 "백두산이"를 "백두산"과 조사 "이"로 나눠 색인한다. 조사와 어미를 떼어내므로 어형이 달라도 같은 명사로 검색된다. Elasticsearch와 OpenSearch 모두 같은 Lucene Nori를 감싸고 있어 설정 방식이 거의 동일하다.
형태소 분석기(morphological analyzer)는 문장을 의미를 가진 최소 단위인 형태소로 쪼개고 각 조각에 품사 태그를 붙이는 도구다. 영어는 공백으로 단어가 끊기지만 한국어는 "백두산이"처럼 명사에 조사가 붙어 한 덩어리로 오기 때문에, 공백 기준으로만 자르는 standard 토크나이저로는 검색이 어긋난다. 아래에서 문제 재현, 플러그인 설치, analyzer 설정, 사용자 사전과 동의어, _analyze로 토큰을 검증하는 순서로 살펴본다.
2. standard 토크나이저로는 왜 안 되나
기본 standard analyzer는 한글을 공백과 문장부호 기준으로만 자른다. 조사가 붙은 채로 토큰이 만들어진다.
POST _analyze
{
"analyzer": "standard",
"text": "동해물과 백두산이"
}
결과 토큰은 동해물과, 백두산이 두 개다. 조사가 붙어 있으니 사용자가 "백두산"이라고 정확히 명사만 입력하면 매칭되지 않는다. 반대로 nori를 통과시키면 조사가 떨어져 나간다.
POST _analyze
{
"analyzer": "nori",
"text": "동해물과 백두산이"
}
토큰은 동해, 물, 백두산이 된다. 조사 "과"와 "이"는 뒤에서 다룰 품사 필터가 제거한다.
3. 플러그인 설치
Nori는 기본 배포에 포함되지 않는다. 노드마다 설치하고 재시작해야 한다.
Elasticsearch
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-nori sudo systemctl restart elasticsearch
OpenSearch
sudo /usr/share/opensearch/bin/opensearch-plugin install analysis-nori sudo systemctl restart opensearch
설치된 플러그인은 다음으로 확인한다.
GET _cat/plugins?v name component version node-1 analysis-nori 8.15.0
여기서 한 번 걸린다. 클러스터의 모든 노드에 설치하고 각 노드를 재시작해야 한다. 한 노드만 설치하면 그 노드로 요청이 가지 않는 한 unknown tokenizer [nori_tokenizer] 같은 에러가 산발적으로 뜬다. 버전 번호는 클러스터 버전에 맞춰 표시되므로 위 출력과 다를 수 있다.
4. nori analyzer 기본 사용
플러그인을 설치하면 바로 쓸 수 있는 nori analyzer가 생긴다. 이 analyzer는 세 가지를 묶은 것이다. nori_tokenizer로 형태소를 자르고, nori_part_of_speech 필터로 조사, 어미 같은 불필요한 품사를 걸러내고, lowercase 필터로 영문을 소문자화한다.
인덱스를 만들 때 필드에 지정한다.
PUT sarc-articles
{
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "nori" },
"content": { "type": "text", "analyzer": "nori" }
}
}
}
기본 nori analyzer로 충분한 경우가 많다. 복합명사 분해나 사용자 사전이 필요할 때만 아래처럼 커스텀 analyzer를 정의한다.
5. nori_tokenizer 옵션
토큰화 동작을 조정하는 핵심 옵션은 decompound_mode다. "가곡역" 같은 복합명사를 어떻게 처리할지 정한다.
- none: 분해하지 않는다.
가곡역하나로 남는다. - discard: 분해하고 원형은 버린다.
가곡,역. 기본값이다. - mixed: 분해하되 원형도 남긴다.
가곡역,가곡,역.
검색 재현율을 높이려면 mixed가 유리하다. "가곡역"으로도, "가곡"으로도 걸린다. 다만 색인이 커지고 노이즈도 늘어난다. 정확도 위주라면 discard를 그대로 둔다.
그 밖에 자주 쓰는 옵션이다.
- discard_punctuation: 문장부호 토큰 제거 여부. 기본값
true. - user_dictionary: 사용자 사전 파일 경로(설정 디렉터리 기준 상대 경로).
- user_dictionary_rules: 사전을 파일 대신 배열로 인라인 정의.
- lenient: 사용자 사전에 중복 항목이 있을 때 에러 대신 무시. 기본값
false.
PUT sarc-articles
{
"settings": {
"analysis": {
"tokenizer": {
"nori_mixed": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
}
},
"analyzer": {
"korean": {
"type": "custom",
"tokenizer": "nori_mixed",
"filter": ["nori_part_of_speech", "lowercase"]
}
}
}
}
}
6. 사용자 사전으로 고유명사 지키기
형태소 분석기는 사전에 없는 신조어나 브랜드명, 제품명을 엉뚱하게 쪼갠다. 예를 들어 "삼성전자서비스"가 의도와 다르게 잘릴 수 있다. 이럴 때 사용자 사전(user_dictionary)에 단어를 등록해 원하는 대로 자르게 만든다.
사전 파일은 한 줄에 한 항목이다. 단어만 쓰면 그 단어를 하나의 토큰으로 유지하고, 뒤에 공백으로 분해형을 나열하면 그렇게 쪼갠다.
# userdict_ko.txt 삼성전자서비스 세종시 세종 시 c++
파일은 설정 디렉터리에 둔다. Elasticsearch는 /etc/elasticsearch/, OpenSearch는 /etc/opensearch/ 아래이며, user_dictionary에는 이 디렉터리 기준 상대 경로를 적는다.
sudo cp userdict_ko.txt /etc/elasticsearch/ sudo chown elasticsearch:elasticsearch /etc/elasticsearch/userdict_ko.txt
"tokenizer": {
"nori_user": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary": "userdict_ko.txt"
}
}
파일을 배포하기 번거로우면 인라인으로 넣는다. 테스트 인덱스에서는 이 방식이 편하다.
"tokenizer": {
"nori_user": {
"type": "nori_tokenizer",
"user_dictionary_rules": ["삼성전자서비스", "c++"]
}
}
사전을 고쳐도 이미 색인된 문서는 바뀌지 않는다. 사전 파일을 바꾸면 인덱스를 닫았다 열거나(_close 후 _open) 재색인해야 새 사전이 적용된다. 운영 중이라면 재색인 쪽이 안전하다.
7. 품사 필터와 한자 읽기 변환
nori_part_of_speech 필터는 지정한 품사 태그의 토큰을 버린다. 검색에 방해되는 조사, 어미, 문장부호를 걸러내는 역할이다. 기본 제거 대상(stoptags)은 다음과 같다.
E, IC, J, MAG, MAJ, MM, SP, SSC, SSO, SC, SE, XPN, XSA, XSN, XSV, UNA, NA, VSV
여기서 J가 조사, E가 어미다. 이 둘이 기본으로 제거되기 때문에 "백두산이"에서 "이"가 사라진다. 특정 품사를 살리거나 더 지우고 싶으면 stoptags를 직접 지정한다. 기본값을 덮어쓰므로 유지할 태그까지 전부 나열해야 한다.
"filter": {
"my_pos": {
"type": "nori_part_of_speech",
"stoptags": ["E", "J", "SP", "SC", "SE"]
}
}
nori_readingform 필터는 한자를 한글 읽기로 바꾼다. "中國"을 "중국"으로 색인해 한글로 검색되게 한다. 한자 문서가 섞여 있으면 analyzer 필터 목록에 추가한다.
8. 동의어 붙이기
동의어는 Nori 전용 기능이 아니라 표준 synonym 토큰 필터로 처리한다. "노트북"과 "랩탑"을 같은 검색어로 묶는 식이다. nori_tokenizer 뒤에 필터로 얹으면 된다.
"filter": {
"my_synonym": {
"type": "synonym",
"synonyms": [
"노트북, 랩탑",
"tv, 티비, 텔레비전"
]
}
},
"analyzer": {
"korean": {
"type": "custom",
"tokenizer": "nori_user",
"filter": ["nori_part_of_speech", "lowercase", "my_synonym"]
}
}
동의어 사전이 커지면 synonyms 배열 대신 synonyms_path로 파일을 참조한다. 사용자 사전과 마찬가지로 설정 디렉터리 기준 상대 경로다. 순서에 주의한다. lowercase 뒤에 synonym을 두면 대소문자 정규화된 토큰에 동의어가 적용돼 "TV"와 "tv"를 한 번에 처리한다.
9. _analyze로 토큰 검증
설정을 바꿨으면 실제로 어떤 토큰이 나오는지 _analyze로 확인한다. 이 단계를 건너뛰면 검색이 안 되는 이유를 색인 뒤에야 알게 된다. 인덱스에 정의한 analyzer를 이름으로 지정해 검사한다.
POST sarc-articles/_analyze
{
"analyzer": "korean",
"text": "삼성전자서비스에 문의했다"
}
토큰만 간단히 보려면 explain 없이 위처럼 요청한다. 품사 태그까지 보고 싶으면 토크나이저를 직접 지정하고 explain을 켠다.
POST _analyze
{
"tokenizer": "nori_tokenizer",
"explain": true,
"attributes": ["posType", "leftPOS", "rightPOS"],
"text": "백두산이 보인다"
}
응답의 tokens 배열에서 각 토큰의 leftPOS 값으로 품사를 확인한다. 조사 "이"에는 J 계열 태그가 붙어 있고, 기본 analyzer라면 품사 필터가 이를 제거한다. 사용자 사전에 등록한 "삼성전자서비스"가 통째로 한 토큰으로 나오는지, decompound_mode대로 복합명사가 쪼개지는지 이 출력으로 눈으로 확인한다.
10. 마무리
한글 검색은 플러그인 설치, nori analyzer 지정, _analyze 검증 세 단계만 지키면 대부분 자리를 잡는다. 재현율이 필요하면 decompound_mode를 mixed로, 고유명사가 깨지면 사용자 사전으로, 표현이 다양하면 동의어 필터로 대응한다. Elasticsearch와 OpenSearch는 설치 명령과 설정 디렉터리 경로만 다르고 analyzer 설정은 동일하다. 사전을 바꾼 뒤에는 재색인이 필요하다는 점만 기억하면 운영 중에도 검색 품질을 꾸준히 손볼 수 있다.