본문으로 바로가기
반응형

루씬에서 Indexing 작업은 주어진 Text를 Term으로 쪼개서 검색 가능한 최소한의 단위로 만드는 작업이라고 볼수 있습니다.

그리고, 그 단어들을 검색하기 편하도록 거꾸로 정리 놓는거죠. (Inverted index 말입니다.)


이번에는 indexing 작업중에 Text를 쪼개서 Term으로 만드는 방법에 대해서 설명합니다.

이렇게 대상 Text를 잘게 잘라서 term을 만드는 작업은 Analyzer가 수행합니다.


이 글은 "실전비급 아파치 루씬 7"을 참고하였습니다.

자세한 설명 및 다양한 예제는 해당 책을 구매하여 확인하시기 바랍니다.


※ 모든 예제 코드는 Kotlin으로 작성되었습니다. (Lucene library 원본 코드 제외)


크게 나누어 분석은 아래의 단계를 거칩니다.

CharFilter -> Tokenizer -> TokenFilter


CharFilter

HTML이나, 임의의 패턴등을 입력된 text source에서 제거합니다.

Tokenizer를 수행하기 전에 수행하는 사전 작업으로 이는 토큰으로 나눠지기 전에 offset을 보장하기 위함입니다.

  • HTMLStripCharFilter: HTML 구문 제거
  • MappingCharFilter: 백스페이스, 탭, 개행문자 등을 유니코드로 변경.

TokenStream

Source text를 Token으로 나눈 이후 token이 순서대로 나열된 상태를 Token stream 이라고 합니다.
실제로 이 작업은  TokenStream class에서 수행됩니다.

ex) He has children and we love them -> "He" "has" "children" "and" "we" "love" "them"


AttributeSource

AttributeSoucre는 TokenStream의 부모 클래스로 Token의 메타 정보를 담고 있습니다.
따라서 TokenStream이 생성될때 ArrtibuteSrouce에 보고자 하는 정보를 등록해 놓으면 그때 그때의 snap shot을 확인할 수 있습니다.

fun main(args: Array) {
    val source = "He has children and we love them"

    val analyzer = StandardAnalyzer()
    val tokenStream = analyzer.tokenStream("Sample", source)

    tokenStream.use {
        // 속성 등록
        // 토큰의 text
        val charTermAttr = it.addAttribute(CharTermAttribute::class.java)

        // 토큰의 offset 정보
        val offsetAttr = it.addAttribute(OffsetAttribute::class.java)

        // 토큰이 차지하는 자리의 개수
        val positionLengthAttr = it.addAttribute(PositionLengthAttribute::class.java)

        // payload 정보를 get/set 할수 있다. (Payload 기반 쿼리르 ㄹ할때 Score에 영향을 준다)
        val payloadAttr = it.addAttribute(PayloadAttribute::class.java)

        // token의 유형
        val TypeAttr = it.addAttribute(TypeAttribute::class.java)

        // 현재 읽어야 하는 토큰을 위치 반환. 생략된 토큰이 있다면 샏략된 개수만큼 더해진 값이 써진다.
        val positionIncrementAttr = it.addAttribute(PositionIncrementAttribute::class.java)

        // token을 키워드로 표시할 때 사용
        val keywordAttr = it.addAttribute(KeywordAttribute::class.java)

        // Token Stream 초기화
        it.reset()
        while (it.incrementToken()) {
            println("charTerm: $charTermAttr | " +
                    "offsetAttr: (start=${offsetAttr.startOffset()} end=${offsetAttr.endOffset()}) | " +
                    "payloadAttr: ${payloadAttr.payload} | " +
                    "positionLengthAttr: ${positionLengthAttr.positionLength} | " +
                    "TypeAttribute: ${TypeAttr.type()} | " +
                    "positionIncrementAttribute:${positionIncrementAttr.positionIncrement} | " +
                    "keywordAttr:${keywordAttr.isKeyword}"
            )
        }
    }
}


TokenStream에 addAttribute()로 속성을 추가하면 snap shot이 등록되어 그때그대의 token 정보를 볼수 있습니다.

위 예제에서는 incrementToken() 함수로 token을 하나씩 넘기면서 해당 Token의 정보를 출력합니다.


결과는 아래와같습니다.

charTerm: he | offsetAttr: (start=0 end=2) | payloadAttr: null | positionLengthAttr: 1 | TypeAttribute: | positionIncrementAttribute:1 | keywordAttr:false 

charTerm: has | offsetAttr: (start=3 end=6) | payloadAttr: null | positionLengthAttr: 1 | TypeAttribute: | positionIncrementAttribute:1 | keywordAttr:false 

charTerm: children | offsetAttr: (start=7 end=15) | payloadAttr: null | positionLengthAttr: 1 | TypeAttribute: | positionIncrementAttribute:1 | keywordAttr:false 

charTerm: we | offsetAttr: (start=20 end=22) | payloadAttr: null | positionLengthAttr: 1 | TypeAttribute: | positionIncrementAttribute:2 | keywordAttr:false 

charTerm: love | offsetAttr: (start=23 end=27) | payloadAttr: null | positionLengthAttr: 1 | TypeAttribute: | positionIncrementAttribute:1 | keywordAttr:false 

charTerm: them | offsetAttr: (start=28 end=32) | payloadAttr: null | positionLengthAttr: 1 | TypeAttribute: | positionIncrementAttribute:1 | keywordAttr:false

이중에서 "and" 는 불용어로 삭제된것을 알수 있습니다. (StopFilter에 의해 걸러짐)

따라서 "we"positionIncrementAttribute의 값은 2 입니다.


Tokenizer

Source text를 Token으로 분리하며, 주요 Tokenizer는 아래와 같습니다.
  • Standard Tokenizer: lucene core에서 제공하는 기본 analyzer로 유니코드 텍스트 분할 알고리즘에 명시된 단어 분리 규칙을 따름.
  • Whitespace Tokenizer: 공백 문자로 텍스트를 분할
  • NGram Tokenizer: N-Gram 모델을 이용해 분할
  • LowerCase Tokenizer: 영문 분할용으로 대문자를 소문자로 바꿔서 분할하며, LetterTokenizer + LowerCaseFitler의 조합
  • EdgeNGram Tokenizer: 입력토큰의 시작부분에서 N-Gram을 만듬.

TokenFilter

Tokenizer에 의해서 분리된 토큰을 정제합니다.
  • StandardFilter: StandardTokenizer로 추출된 토큰을 표준화 합니다.
  • LowerCaseFilter: 소문자로 통일 시킵니다.
  • StopFilter: 불용어를 제거합니다 ex) 'a', 'the' , 'is', 'and' ...
 

Analyzer

Analyzer는 Tokenizer와 TokenFitler로 구성됩니다.
실제 Analyzer class는 abstract이며, 각각의 Analyzer는 Analyzer 클래스를 상속받아 아래 abstract 함수를 override합니다.

abstract createComponents(String fieldName)

-> 사용할 Tokenizer와 TokenFitler를 담는 TokenStreamComponents를 return합니다.


org.apache.lucene.analysis.Analyzer.java 내부의 crateCompoenets 정의

/**
   * Creates a new {@link TokenStreamComponents} instance for this analyzer.
   * 
   * @param fieldName
   *          the name of the fields content passed to the
   *          {@link TokenStreamComponents} sink as a reader

   * @return the {@link TokenStreamComponents} for this analyzer.
   */
  protected abstract TokenStreamComponents createComponents(String fieldName);


org.apache.lucene.analysis.Analyzer.java 내부에 정의된 TokenStreamComponents 클래스 내용

/**
   * This class encapsulates the outer components of a token stream. It provides
   * access to the source ({@link Tokenizer}) and the outer end (sink), an
   * instance of {@link TokenFilter} which also serves as the
   * {@link TokenStream} returned by
   * {@link Analyzer#tokenStream(String, Reader)}.
   */
  public static class TokenStreamComponents {
    /**
     * Original source of the tokens.
     */
    protected final Tokenizer source;
    /**
     * Sink tokenstream, such as the outer tokenfilter decorating
     * the chain. This can be the source if there are no filters.
     */
    protected final TokenStream sink;
    
    /** Internal cache only used by {@link Analyzer#tokenStream(String, String)}. */
    transient ReusableStringReader reusableStringReader;

    /**
     * Creates a new {@link TokenStreamComponents} instance.
     * 
     * @param source
     *          the analyzer's tokenizer
     * @param result
     *          the analyzer's resulting token stream
     */
    public TokenStreamComponents(final Tokenizer source,
        final TokenStream result) {
      this.source = source;
      this.sink = result;
    }
    
    /**
     * Creates a new {@link TokenStreamComponents} instance.
     * 
     * @param source
     *          the analyzer's tokenizer
     */
    public TokenStreamComponents(final Tokenizer source) {
      this.source = source;
      this.sink = source;
    }

    /**
     * Resets the encapsulated components with the given reader. If the components
     * cannot be reset, an Exception should be thrown.
     * 
     * @param reader
     *          a reader to reset the source component
     */
    protected void setReader(final Reader reader) {
      source.setReader(reader);
    }

    /**
     * Returns the sink {@link TokenStream}
     * 
     * @return the sink {@link TokenStream}
     */
    public TokenStream getTokenStream() {
      return sink;
    }

    /**
     * Returns the component's {@link Tokenizer}
     *
     * @return Component's {@link Tokenizer}
     */
    public Tokenizer getTokenizer() {
      return source;
    }
  }



따라서 Analyzer를 상속받아서 위 함수를 구현하여 직접 custom analyzer를 만들어 사용할 수도 있습니다.

Custom analyzer를 만드는건 추후 Analyzer 분석기 (2/2)에서 포스팅 합니다.


추가적으로 루씬은 core 라이브러리의 기본 분석기 이외에도 lucene-analyzer-common 라이브러리에서 추가적인 Analyzer를 제공합니다.

추가 Analyzer

  • Common: 언어나 비지니스 분야에 공통적으로 사용을 위한 분석기
  • ICU: 유니코드 표준을 지원하며 ICUInternational compponents for Unicode를 루씬에서 사용하기 위한 모듈
  • Kuromoji: 일본어 형태소 분석기
  • Phonetic: 비슷한 발음을 검색
  • Smart Chinese: 중국어 간체 분석
  • Stempel: 폴란드 언어의 동사 원형 추출 알고리즘 제공
  • UIMA: 아파치 UIMA(테스트간의 관련성을 찾아내는 비정보화 정보관리 아키텍쳐)와 통합분석 제공



StandardAnalyzer

lucene-core에 포함된 analyzer로 이메일, 주소, 약자, 중국어, 일본어, 한국어, 영어등의 테긋트를 정교화된 유니코드 텍스트 분할 알고리즘을 사용하여 분할합니다.

/**
 * Filters {@link StandardTokenizer} with {@link StandardFilter}, {@link
 * LowerCaseFilter} and {@link StopFilter}, using a list of
 * English stop words.
 */
public final class StandardAnalyzer extends StopwordAnalyzerBase {

  ...
  
  static {
    final List stopWords = Arrays.asList(
      "a", "an", "and", "are", "as", "at", "be", "but", "by",
      "for", "if", "in", "into", "is", "it",
      "no", "not", "of", "on", "or", "such",
      "that", "the", "their", "then", "there", "these",
      "they", "this", "to", "was", "will", "with"
    );
    final CharArraySet stopSet = new CharArraySet(stopWords, false);
    ENGLISH_STOP_WORDS_SET = CharArraySet.unmodifiableSet(stopSet); 
  }
  
  ...

  @Override
  protected TokenStreamComponents createComponents(final String fieldName) {
    final StandardTokenizer src = new StandardTokenizer();
    src.setMaxTokenLength(maxTokenLength);
    TokenStream tok = new StandardFilter(src);
    tok = new LowerCaseFilter(tok);
    tok = new StopFilter(tok, stopwords);
    return new TokenStreamComponents(src, tok) {
      @Override
      protected void setReader(final Reader reader) {
        // So that if maxTokenLength was changed, the change takes
        // effect next time tokenStream is called:
        src.setMaxTokenLength(StandardAnalyzer.this.maxTokenLength);
        super.setReader(reader);
      }
    };
  }


1. StandardTokenizer()를 사용하여 Tokenize 처리

2. StandrardFilter()를 생성 (체인 생성을 위한 Filter - 다른작업은 하지 않음)

3. LowerCaseFilter()를 이용하여 소문자로 변경

4. StopFilter()통하여 불용어 제거


불용어는 클래스 최 상단에 정의되어 있습니다.

"My name is hong gil dong \n I'm a super hero \n Everyone likes me"

위 문구를 분석하면 아래와 같이 Tokenize 됩니다.

my | name | hong | gil | dong | i'm | super | hero | everyone | likes | me |


SimpleAnalyzer

lucene-analyzer-common에 존재하는 analyzer로 문자가 아닌것을 기준으로 나누고 소문자로 변경합니다.
내부적으로 LetterTokenizer를 상속받은 LowerCaseTokenizer를 사용합니다.
아시아권 문자는 공백같은 단위로 쪼개면 원하는 결과를 얻을수 없습니다.

/** An {@link Analyzer} that filters {@link LetterTokenizer} 
 *  with {@link LowerCaseFilter} 
 **/
public final class SimpleAnalyzer extends Analyzer {

  /**
   * Creates a new {@link SimpleAnalyzer}
   */
  public SimpleAnalyzer() {
  }
  
  @Override
  protected TokenStreamComponents createComponents(final String fieldName) {
    return new TokenStreamComponents(new LowerCaseTokenizer());
  }

  @Override
  protected TokenStream normalize(String fieldName, TokenStream in) {
    return new LowerCaseFilter(in);
  }
}

분석결과

my | name | is | hong | gil | dong | i | m | a | super | hero | everyone | likes | me |

WhitespaceAnalyzer

lucene-analyzer-common에 존재하는 analyzer로 공백이나 탭등을 기준으로 tokenize를 수행합니다.
특수문자의 제거나, 소문자 변경도 하지 않습니다.
/**
 * An Analyzer that uses {@link WhitespaceTokenizer}.
 **/
public final class WhitespaceAnalyzer extends Analyzer {
  
  /**
   * Creates a new {@link WhitespaceAnalyzer}
   */
  public WhitespaceAnalyzer() {
  }
  
  @Override
  protected TokenStreamComponents createComponents(final String fieldName) {
    return new TokenStreamComponents(new WhitespaceTokenizer());
  }
}


분석 결과

My | name | is | hong | gil | dong | I'm | a | super | hero | Everyone | likes | me |


StopAnalyzer

lucene-analyzer-common에 존재하는 analyzer로 불용어를 제거하고 소문자로 변경합니다.
/** 
 * Filters {@link LetterTokenizer} with {@link LowerCaseFilter} and {@link StopFilter}.
 */
public final class StopAnalyzer extends StopwordAnalyzerBase {
  
  ...

  /**
   * Creates
   * {@link org.apache.lucene.analysis.Analyzer.TokenStreamComponents}
   * used to tokenize all the text in the provided {@link Reader}.
   * 
   * @return {@link org.apache.lucene.analysis.Analyzer.TokenStreamComponents}
   *         built from a {@link LowerCaseTokenizer} filtered with
   *         {@link StopFilter}
   */
  @Override
  protected TokenStreamComponents createComponents(String fieldName) {
    final Tokenizer source = new LowerCaseTokenizer();
    return new TokenStreamComponents(source, new StopFilter(source, stopwords));
  }

  @Override
  protected TokenStream normalize(String fieldName, TokenStream in) {
    return new LowerCaseFilter(in);
  }
}


createComponents() 내부에서 LowerCaseTokenizer()를 생성하고 StopFilter를 생성하여 TokenStreamComponents를 생성하는 코드를 볼 수 있습니다.


분석결과
my | name | hong | gil | dong | i | m | super | hero | everyone | likes | me |

KeywordAnalyzer

텍스트를 분할하지 않고 하나의 토큰으로 취급합니다.
**
 * "Tokenizes" the entire stream as a single token. This is useful
 * for data like zip codes, ids, and some product names.
 */
public final class KeywordAnalyzer extends Analyzer {
  public KeywordAnalyzer() {
  }

  @Override
  protected TokenStreamComponents createComponents(final String fieldName) {
    return new TokenStreamComponents(new KeywordTokenizer());
  }
}


분석결과
My name is hong gil dong
I'm a super hero
Everyone likes me |

반응형