[Elasticsearch] Nori Tokenizer 파헤치기 (2) - Viterbi 알고리즘

2025. 11. 12. 11:37·ELK

 

저번 포스팅에서는 Nori tokenizer의 핵심 자료 구조인  Lattice(격자) 구조를 정리해보았습니다.

2025.11.11 - [ELK] - [Elasticsearch] Nori Tokenizer 파헤치기 - Lattice 구조

 

[Elasticsearch] Nori Tokenizer 파헤치기 - Lattice 구조

Elasticsearch를 활용하여 한글 기반 검색을 구현하다가 Nori Tokenizer를 사용하게 되었습니다. 형태소 단위로 토큰을 분석하는 것이 흥미로웠습니다. Lucene의 NoriTokenizer 내부 구현, 특히 Viterbi lattice 기

featherdale.tistory.com

이번 포스팅에서는 Lattice 구조를 어떻게 활용하여 분석을 하게되는지 살펴보도록 하겠습니다.

 

개요

Viterbi는 문장에 대해 가능한 분석(형태소 시퀀스) 경로들 중 총비용이 가장 작은 경로를 찾는 동적계획법 알고리즘입니다. HMM/품사 전이 모델(POS)에서 최적 경로를 구하는 표준 기법을 말합니다.

 

Viterbi의 목표

Viterbi의 목표는 다음 목적함수를 최소화하는 경로를 찾는 것입니다.

목적 함수

 

  • w​: i번째 형태소 노드,
  • pos​: 그 노드의 left/right 품사 ID
  • wordCost: 사전 빈도/우선도 기반 고정비용
  • connCost: 전이 행렬(품사 bigram)에서 가져오는 비용
  • penalties: 숫자/기호/미지어 길이 등의 경험적 보정

 

즉, 사전에 있고, 품사 전이가 자연스러운 경로가 유리하도록 비용을 설계하는 것이 목적입니다.

 

Viterbi 동작 순서

Viterbi가 동작하는 순서는 다음과 같습니다.

  • BOS/EOS를 포함해 격자 뼈대 마련
  • 사전/미지어/사용자 사전으로 후보 노드 채우기
  • Forward DP: 전이비용+단어비용(+패널티) 누적, 각 노드의 bestPrev 결정
  • Backtrace: EOS에서 bestPrev 따라가며 최적 경로 복구
  • Emit(방출): 경로의 노드를 토큰으로 변환(표면형/기본형/품사/offset, decompound 옵션 등)

위 내용을 좀 더 자세하게 다뤄보도록 하겠습니다.

 

Viterbi의 목적 함수

w_i : i번째 형태소 노드

w_i는 입력 문장에서 특정 위치에 해당하는 형태소 후보입니다.
예를 들어 “학생회관에” 에서는

  • w₁ = “학생”
  • w₂ = “회관”
  • w₃ = “에”

이런 식으로 나눠집니다.

 

즉, w_i는 문장에서 i번째 단어 또는 형태소라는 뜻입니다.
Viterbi는 이런 형태소들의 연결(w₁ → w₂ → w₃ …)을 경로로 보고, 그 경로의 총 비용을 계산해서 가장 자연스러운 문장 해석을 찾습니다.

 

pos_i^L / pos_i^R : 품사 전이 정보 (왼쪽/오른쪽 품사 ID)

이건 각 형태소가 문법적으로 어떤 품사와 연결될 수 있는지를 나타내는 정보입니다.

  • pos_i^L (Left POS ID): 이 형태소가 앞 단어와 연결될 때의 품사 ID
  • pos_i^R (Right POS ID): 이 형태소가 뒤 단어와 연결될 때의 품사 ID

“학생(명사)” → “회관(명사)” 은 부자연스럽지만,
“학생(명사)” → “회관(명사파생복합어)” 은 자연스러울 수 있습니다.

 

이때 전이 가능성(자연스러움의 정도)을 ConnectionCosts 행렬에서 찾게 됩니다.

품사 전이 정보

즉, 이 값이 낮을수록 이 품사 연결은 자연스럽다는 뜻입니다.

 

wordCost : 단어 자체의 기본 비용

이건 형태소 그 자체에 부여된 기본 점수입니다.
사전(Dictionary)에 등록된 단어들은 각자 빈도, 중요도에 따라 기본비용이 다릅니다.

  • 많이 쓰이는 단어 → 비용 낮음 (선호됨)
  • 드물거나 긴 단어 → 비용 높음 (덜 선호됨)
단어 wordCost 의미
학생 1000 일반 명사, 자주 등장
학생회관 1200 복합명사, 상대적으로 덜 등장
학생회 1300 특정 의미, 덜 자주 등장

이건 형태소 후보 자체가 얼마나 자연스러운 단어인지를 반영한 값입니다.

 

connCost : 품사 전이(transition) 비용

이건 앞 단어의 품사와 뒤 단어의 품사가 연결될 때의 자연스러움을 수치화한 비용입니다.

이전 품사 (Right ID) 다음 품사 (Left ID) connCost 의미
명사 조사 50 자연스러움 ↑
명사 명사 400 약간 부자연스러움
조사 동사 150 문법적으로 가능
조사 조사 1000 거의 불가능

그래서 문장 내의 각 단어가 이어질 때마다, 전이 행렬(ConnectionCosts) 에서 이 값을 가져와서 누적하는 형태입니다.

전이 행렬

 

 

penalties : 패널티 (보정항)

이건 단어 자체의 품질을 보정하는 추가비용을 뜻합니다. 실제 언어는 단순히 품사 전이만으로 설명이 안 되기 때문에,
몇 가지 경험적 규칙을 가산해서 조정합니다.

 

상황 패널티 이유
미지어(unknown) +500 사전 밖 단어는 신뢰도↓
너무 긴 단어 +300 지나치게 긴 토큰은 오분석일 확률↑
특수문자/이모지 +700 형태소 분석 대상이 아님
한 글자 미지어 +200 너무 짧은 단위는 의미 불명확
숫자 시퀀스 +100 종종 별도로 처리됨

즉, penalties는 언어적 직관을 보정하기 위한 추가비용입니다.

 

Viterbi 동작 원리

위에서 간략하게 설명한 동작 순서를 자세하게 알아보도록 하겠습니다.

Lattice 격자 뼈대 마련 (BOS/EOS 포함)

Lattice에 대해서는 저번 포스팅에 알아보았습니다.

  • x축 (가로축): 입력 문자의 위치 (offset)
  • y축 (세로축): 해당 위치에서 가능한 형태소 후보들
  • 노드(node): 형태소 후보 (단어, 품사, 사전 ID, 비용 등)
  • 엣지(edge): 형태소 간 연결 가능성 (품사 전이 가능 여부)

추가로, 문장의 논리적 시작/끝을 위해 BOS/EOS 가상 노드를 둡니다.

BOS → [pos=0 후보…] → [pos=… 후보…] → [pos=n 후보…] → EOS


이 격자가 Viterbi의 DP 테이블이 됩니다.

후보 노드 채우기 (사전/미지어/사용자 사전)

각 문자 위치(pos)마다 사전, 사용자 사전, 미지어(unknown) 사전에서 형태소 후보를 찾아 ViterbiNode 로 만든 뒤 lattice에 삽입합니다.

Forward DP (전이비용 + 단어비용 + 패널티 누적, bestPrev 결정)

각 노드의 totalCost 를 계산하면서 최소 비용을 갱신하고, 그 때의 이전 노드(bestPrev) 를 기록합니다.

// lucene/analysis/nori/viterbi/ViterbiSearcher.java
private void forwardSearch(ViterbiLattice lattice) {
    for (int pos = 0; pos <= lattice.getEndPos(); pos++) {
        for (ViterbiNode cur : lattice.getNodes(pos)) {
            int bestCost = Integer.MAX_VALUE;
            ViterbiNode bestPrev = null;
            for (ViterbiNode prev : lattice.getNodes(cur.getStartPos())) {
                if (prev.getEndPos() != cur.getStartPos()) continue;
                int transition = connCosts.get(prev.getRightId(), cur.getLeftId());
                int cost = prev.getTotalCost()
                        + transition
                        + cur.getWordCost()
                        + computePenalty(cur);
                if (cost < bestCost) {
                    bestCost = cost;
                    bestPrev = prev;
                }
            }
            cur.setTotalCost(bestCost);
            cur.setBestPrevNode(bestPrev);
        }
    }
}
// lucene/analysis/ko/dict/ConnectionCosts.java
public int get(int leftId, int rightId) {
    return costTable[leftId * numRight + rightId];
}

Backtrace (EOS → bestPrev 따라가기)

Forward 단계가 끝나면 EOS에서 bestPrev 포인터를 거슬러 올라가며 최소 비용 경로를 복원합니다.
거꾸로 추적한 뒤 순서를 뒤집어 최종 시퀀스를 얻습니다.

// lucene/analysis/nori/viterbi/ViterbiSearcher.java
private List<ViterbiNode> backtrace(ViterbiNode eosNode) {
    List<ViterbiNode> result = new ArrayList<>();
    ViterbiNode node = eosNode.getBestPrevNode();
    while (node != null && node.getType() != TokenType.BOS) {
        result.add(node);
        node = node.getBestPrevNode();
    }
    Collections.reverse(result);
    return result;
}

Emit (방출: 형태소 토큰 출력)

복원된 최적 경로의 각 노드를 Token 객체로 변환하여 출력합니다.
복합명사 분해(decompound) 옵션이나 불용 품사 필터링 등이 이 단계에서 적용됩니다.

// lucene/analysis/ko/KoreanTokenizer.java
private void emit(List<ViterbiNode> bestPath) {
    for (ViterbiNode node : bestPath) {
        Token token = new Token(node.getSurface(), node.getStartOffset(), node.getEndOffset());
        token.setPartOfSpeech(node.getPosTag());
        // 복합명사 처리 옵션
        if (decompoundMode == DecompoundMode.MIXED) {
            emitDecompoundedTokens(node);
        }
        outputToken(token);
    }
}

정리

Lucene Nori가 만들어진 목적을 살펴보았고, 목적 함수가 동작하는 방식을 확인하였습니다.

Input text
  ↓
[BOS/EOS 추가 → 격자 구성]
  ↓
[사전/미지어 후보 노드 추가]
  ↓
[Forward DP: totalCost + bestPrev 계산]
  ↓
[Backtrace로 최적 경로 복원]
  ↓
[Emit 단계에서 Token 생성/출력]

위 과정을 통해 Lattice 구조를 이용하여 Viterbi 알고리즘이 작동하는 방식을 알아보았습니다.

정리해보자면 Nori의 형태소 분석, 즉 Viterbi알고리즘은 가능한 모든 형태소 경로 중에서 단어비용 + 전이비용 + 보정비용의 총합이 가장 작은 경로를 찾는 최적화 과정입니다.

'ELK' 카테고리의 다른 글

[Elasticsearch] 검색 최적화하기 (2) - 마이그레이션(reindex)  (1) 2025.12.17
[Elasticsearch] 검색 최적화하기 (1) - analyzer  (0) 2025.12.15
[Elasticsearch] Nori Tokenizer 파헤치기 (1) - Lattice 구조  (0) 2025.11.11
[Elasticsearch] 인기 검색어 구현하기 (2)  (0) 2025.11.10
[Elasticsearch] 인기 검색어 구현하기 (1)  (2) 2025.11.07
'ELK' 카테고리의 다른 글
  • [Elasticsearch] 검색 최적화하기 (2) - 마이그레이션(reindex)
  • [Elasticsearch] 검색 최적화하기 (1) - analyzer
  • [Elasticsearch] Nori Tokenizer 파헤치기 (1) - Lattice 구조
  • [Elasticsearch] 인기 검색어 구현하기 (2)
ksngh
ksngh
웹 백엔드 개발 블로그입니다. https://github.com/ksngh
  • ksngh
    featherdale
    ksngh
  • 전체
    오늘
    어제
    • 분류 전체보기 (66)
      • 데이터베이스 (10)
      • spring (11)
      • redis (7)
      • ELK (11)
      • 회고 (6)
      • 기타 (12)
        • java (2)
        • 디자인패턴 (2)
        • 영어 (1)
        • 자바스크립트 (1)
        • graphQL (2)
        • 블록체인 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    엘라스틱서치
    대용량데이터베이스
    자료구조
    디자인패턴
    엘라스틱 서치
    Elasticsearch
    단위 테스트
    Elastic Search
    NoriTokenizer
    회고
    spring
    조인의 종류
    연말 회고
    인기검색어 구현
    레디스
    Redis
    Spy
    PostgreSQL
    elastic search in action
    엘라스틱 서치 인 액션
    단위테스트
    gof
    nori tokenizer
    graphql
    데이터베이스
    Mock
    NORI
    대용량 데이터 베이스
    Spring Core
    core
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
ksngh
[Elasticsearch] Nori Tokenizer 파헤치기 (2) - Viterbi 알고리즘
상단으로

티스토리툴바