검색이 안 맞을 때 사람들은 보통 쿼리를 의심한다. 하지만 검색의 90%는 색인할 때 텍스트를 어떻게 쪼갰느냐에서 결정된다. 쿼리는 그 쪼개진 토큰과 매칭될 뿐이다.
이 “쪼개는 과정”이 analyzer다. analyzer는 마법이 아니라 세 단계의 명확한 파이프라인이고, _analyze API로 매 단계의 결과를 눈으로 볼 수 있다. 원리를 알면 “왜 이 검색어가 안 잡히는지”를 추측이 아니라 확인으로 풀 수 있다.
Analyzer는 세 단계 파이프라인이다
문서 하나를 색인하면 텍스트 필드는 이 순서로 처리된다.
원문 → [char_filter] → [tokenizer] → [token_filter] → 토큰들 → 역색인
- char_filter (문자 필터): 토큰화 전에 문자열 자체를 다듬는다. HTML 태그 제거(
html_strip), 문자 치환(&→ “and”), 정규화. - tokenizer (토크나이저): 문자열을 토큰으로 쪼갠다. 정확히 하나만 쓴다.
standard(공백·구두점),nori_tokenizer(한국어 형태소),ngram/edge_ngram. - token_filter (토큰 필터): 쪼개진 토큰들을 변형한다. 여러 개를 순서대로 적용.
lowercase,stop(불용어 제거),synonym,nori_part_of_speech,ngram필터.
핵심: tokenizer는 하나, char_filter와 token_filter는 여러 개를 순서대로. 그리고 token_filter는 순서가 결과를 바꾼다.
_analyze로 단계별 결과를 본다
추측하지 말고 확인하는 도구가 _analyze다.
POST /products/_analyze
{
"analyzer": "nori",
"text": "삼성전자 갤럭시를"
}
결과 토큰: [삼성, 전자, 갤럭시] ← "를"(조사)은 품사 필터로 제거됨
커스텀 analyzer의 각 단계를 따로 찍어볼 수도 있다.
POST /products/_analyze
{
"tokenizer": "nori_tokenizer",
"filter": ["lowercase", "nori_part_of_speech"],
"text": "iPhone15Pro를"
}
“검색이 왜 안 맞지”의 디버깅은 거의 항상 여기서 시작한다. 색인 analyzer와 검색 analyzer에 같은 텍스트를 넣어보고 토큰이 일치하는지 본다. 불일치하면 그게 원인이다.
커스텀 analyzer를 어떻게 조립하나
기본 analyzer로 부족하면 세 단계를 직접 조립한다.
PUT /products
{
"settings": {
"analysis": {
"char_filter": {
"remove_special": { "type": "mapping", "mappings": ["- => ", "/ => "] }
},
"tokenizer": {
"ko_tokenizer": { "type": "nori_tokenizer", "decompound_mode": "mixed" }
},
"filter": {
"ko_pos": { "type": "nori_part_of_speech", "stoptags": ["E", "J"] }
},
"analyzer": {
"ko_index": {
"char_filter": ["remove_special"],
"tokenizer": "ko_tokenizer",
"filter": ["lowercase", "ko_pos"]
}
}
}
}
}
이렇게 만든 ko_index를 필드에 analyzer로 지정한다.
ngram은 무엇을 하나?
형태소 분석은 “의미 단위”로 쪼갠다. 그런데 “갤”만 쳐도 “갤럭시”가 나와야 하는 부분 일치(자동완성, 오타)는 의미 단위로는 안 된다. 이때 ngram을 쓴다.
ngram은 문자열을 n글자씩 슬라이딩 윈도우로 쪼갠다.
"갤럭시", min_gram=2, max_gram=3
2글자: 갤럭, 럭시
3글자: 갤럭시
→ 토큰: [갤럭, 럭시, 갤럭시]
이제 “럭시”로 검색해도 매칭된다. 부분 일치가 된다.
edge_ngram은 앞에서부터만 자른다. 자동완성 전용이다.
"갤럭시", min_gram=1, max_gram=4 (edge)
→ 토큰: [갤, 갤럭, 갤럭시]
“갤”, “갤럭”을 치면 매칭되지만 “럭시”(중간)는 안 된다. 자동완성은 앞에서부터 입력하므로 edge_ngram이 맞고, 인덱스도 ngram보다 작다.
ngram의 함정 — 인덱스 폭발과 검색 시 재적용
ngram은 강력한 만큼 비싸다. 두 가지를 반드시 알아야 한다.
① 인덱스 크기 폭발. min_gram과 max_gram 차이가 크면 토큰이 기하급수적으로 늘어난다. 긴 텍스트에 min=1, max=10을 걸면 토큰 수가 폭발해 인덱스가 수 배로 커진다. ES가 max_ngram_diff(기본 1)로 차이를 제한하는 이유다. 늘리려면 명시적으로 풀어야 한다.
"settings": { "index.max_ngram_diff": 3 }
② 검색 시 ngram을 다시 적용하면 안 된다. 색인 때 edge_ngram으로 “갤, 갤럭, 갤럭시”를 만들어뒀는데, 검색어 “갤럭”에도 edge_ngram을 또 적용하면 “갤, 갤럭”으로 쪼개져 “갤”만 든 엉뚱한 문서까지 매칭된다. 그래서 색인 analyzer는 edge_ngram, 검색 analyzer는 ngram을 안 쓰는 비대칭 구성이 정석이다.
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "edge_ngram_index", // 색인: 앞에서부터 잘라 저장
"search_analyzer": "standard" // 검색: 입력 그대로
}
}
}
ngram vs nori vs fuzzy — 언제 무엇을
세 가지는 경쟁이 아니라 역할이 다르다.
- nori (형태소): 의미 단위 검색. 메인 검색의 기본.
- edge_ngram: 자동완성(prefix 매칭). 미리 색인해 빠름.
- ngram: 부분 일치·일부 오타. 인덱스 비용 큼. 짧은 필드(상품명)에 한정.
- fuzzy: 색인은 그대로 두고 검색 시 편집거리로 오타 흡수. 유연하지만 검색이 느림.
실전 조합: 메인 검색은 nori, 자동완성은 edge_ngram(또는 completion suggester), 오타는 fuzzy fallback. ngram은 꼭 필요한 짧은 필드에만.
튜닝 — 무엇을 측정하고 무엇을 바꾸나
- 인덱스 크기 점검:
_cat/indices?v로 store.size 확인. ngram 도입 후 급증하면 min/max_gram 재검토. - 분석 검증 자동화: 주요 검색어를
_analyze로 돌려 색인/검색 토큰이 일치하는지 테스트로 박제. - max_ngram_diff는 신중히: 늘릴수록 토큰·인덱스 폭발. 1~3 범위에서.
- char_filter로 전처리: 하이픈·슬래시 등 특수문자를 토큰화 전에 정리하면 토큰 품질이 올라간다.
운영상 주의사항
analyzer 설정(특히 tokenizer)은 인덱스 생성 후 변경이 불가하다. char_filter나 token_filter 추가도 기존 인덱스에는 소급 적용되지 않는다. 새 매핑이 필요하면 새 인덱스를 만들고 alias 스위칭으로 무중단 reindex해야 한다. 그래서 analyzer 설계는 처음에 _analyze로 충분히 검증한 뒤 확정하는 게 비용을 아끼는 길이다.