Elasticsearch Analyzer 내부 구조와 ngram 원리

검색이 안 맞을 때 사람들은 보통 쿼리를 의심한다. 하지만 검색의 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_grammax_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로 충분히 검증한 뒤 확정하는 게 비용을 아끼는 길이다.