본문 바로가기

Lucene

[lucene] Analyzer와 Filter (lucene 1.4.X)

루씬의 Analyzer는 아래와 같은 구조로 되어있습니다.

 public TokenStream tokenStream(String fieldName, Reader content) {
  return new GSKoreanSynonymFilter(
      new GSKoreanStopWordFilter(
       new GSKoreanSeperatorWordFilter(
        new GSKoreanSeperatorNameWordFilter(
         new GSKoreanTokenizer(content){} //GSKoreanTokenizer
        ) // GSKoreanSeperatorNameWordFilter
       ) //GSKoreanSeperatorWordFilter
     ),
    engine);
 }



TokenStream을 만들고 정규화를 시키는 Tokenizer와 여기서 만들어진 TokenStream을
사용하여 필터를 하는 필터 클래스들로 구성이 되어있죠. 각각의 필터 클래스들도
TokenStream을 리턴합니다.

Tokenizer나 TokenFilter 모두 TokenStream의 하위 클래스입니다. TokenFilter는 대신 TokenStream을 입력받아 새로운 TokenStream을 만들어 내도록 되어있지요.

Filter와 Tokenizer는 next() 혹은 next(Token token) 메서드를 오버라이드 해서 구현해야 하는데

처음에 저 구조를 제대로 이해를 하지 못 해서

StemFilter를 구현 할 때 (어미제거)

// public Token next() throws IOException {
//  Token token = input.next();
//  if(token == null) {
//   return null;
//  }
//
//  String stem_word = stem(new String(token.termBuffer(), 0, token.termLength()));
//
//  if( stem_word == null) {
//   return null;
//  }
//
//  Token result = new Token(
//    stem_word,
//    token.startOffset(),
//    token.endOffset(),
//    token.type());
//
//  result.setPositionIncrement(token.getPositionIncrement());
//  return result;
// }



이런식으로 구현을 했었습니다. 어미제거시 제거하고 남은 단어가 없으면 null을 리턴시켜버렸던거죠..

이랬더니 "하겠습니다"라는 토큰이 들어와서 "하겠습니다" 라는 어미를 제거해버리니
Analyzer가 그냥 끝나버립니다.

왜 그러지 왜 그러지 고민 많이 했더랍니다. -.-

이래서 공부를 해야하는디....

그래서, 루씬의 StopFilter 소스를 열어보니..

public final Token next(Token result) throws IOException {
    // return the first non-stop word found
    int skippedPositions = 0;
    while((result = input.next(result)) != null) {
      if (!stopWords.contains(result.termBuffer(), 0, result.termLength)) {
        if (enablePositionIncrements) {
          result.setPositionIncrement(result.getPositionIncrement() + skippedPositions);
        }
        return result;
      }
      skippedPositions += result.getPositionIncrement();
    }
    // reached EOS -- return null
    return null;
  }



저렇게 되어있더라는 겁니다.

저건 불용어 제거인데.. 불용어 사전에 없는 단어면 그 Token을 리턴해서 다음 필터에게 넘겨주고 만일 불용어 사전에 있는 단어라면 input.next(result)를 ... 실행하더라는 겁니다.

요약하면..
불용어 사전에 없는 단어면 다음 Filter에게 Token을 리턴해주고 (return result)
불용어 사전에 있는 단어면 다음 Token 가져와. (result = input.next(result))
입니다.^^

즉, 불용어 사전에 있는 단어면 다음 filter에서 그 토큰을 return시키지 않겠다는거지요.

next(Token result) 메서드는 TokenStream에 정의 되어 있는 메서드입니다.

public Token next(Token result) throws IOException {
    return next();
  }

이렇게 되어있죠.. 하위 클래스에서 오버라이드하지 않으면 next()를 호출하게 되어있습니다.

즉 위 Analyzer를 기준으로 한다면..

StopFilter에서 input.next(result)를 실행하면
KoreanStemFilter의 next를 실행하게 되고 이는 다시 Tokenizer의 next()를 실행하게 되어서 Tokenizer에서 다시 새로운 Token을 리턴해주면 그것을 받아 StemFilter에서
Token을 처리하고 다시 StopFilter에서 Token을 받아 처리해주는...

방식으로 되어 있습니다.

추상클래스, 인터페이스, 업캐스팅....

책에서만 보고 실제 왜 이런게 필요할까.... 라고 생각만 했었었는데

디자인 패턴책을 보면서 "아아...... 이런거구나.." 하고 감만 잡고 있다가

이번에 루씬을 보면 고개가 절로 끄덕여집니다.

까먹지 않고 기억하고 싶어서 이렇게 정리를 해봅니다.


-----------------------------------------------------------
위의 내용을 다시 한번 정리하겠습니다.
(순전히 메멘토 같은 제 머리를 위해서입니다 -_-)

일단 Analyzer가 저런식으로 구성이 되어 있기 때문에 사용할 때 Analyzer analyzer = new KoreanAnalyzer (); 이렇게 객체를 생성하고, Token의 리스트를 얻기 위해 보통 아래와 같이 사용합니다.

TokenStream stream = analyzer.tokenStream("fieldname", new StringReader(contents));

그러면 위 메서드에서 각각의 필터들과 Tokenizer를 new 하고 있기 때문에 아래와 같은 순서로 생성객체가 생성됩니다.

GSKoreanTokenizer <init> - 생성자 생성
GSKoreanSeperatorNameWordFilter <init>  - 생성자 생성
GSKoreanSeperatorWordFilter <init>  - 생성자 생성
GSKoreanStopWordFilter <init>  - 생성자 생성
GSKoreanSynonymFilter <init> - 생성자 생성

앞서 언급하였지만 Tokenizer나 Filter 클래스들이나 모드 TokenStream의 하위 클래스들입니다. 각각의 생성자를 보면 아래와 같이 작성이 되어있습니다.

 public GSKoreanStopWordFilter(TokenStream input) {
     super(input);
 }

즉, 상속하고 있는 TokenStream에 생성자의 파라메터로 넘어온 TokenSream을 set하게 되는 것입니다.
(나중에 굉장히 중요하게 사용되는 부분입니다. ^^)

그리고, TokenStream stream = analyzer.tokenStream("fieldname", new StringReader(contents));
에 이어서 stream.next()를 실행하게 됩니다. (뒤에 다시 언급하겠지만 이 TokenStream.next()는 2.4.0 버젼에서 deprecated 되었습니다.)

일단, stream.next()가 실행되게 되면 아래와 같은 순서로 실행된다.

GSKoreanSynonymFilter next  - next() 메서드 실행
GSKoreanStopWordFilter next  - next() 메서드 실행
GSKoreanSeperatorWordFilter next  - next() 실행
GSKoreanSeperatorNameWordFilter next  - next() 실행
GSKoreanTokenizer next  - GSKoreanTokenizer....next
GSKoreanTokenizer next  - GSKoreanTokenizer....return

무슨 구조이길래 저렇게 실행이 되는걸까..
 
각각의 필터들은 동의어 색인이라든가 불용어 제거 혹은 복합명사 추출등의 작업을 하게 된다.
일반적으로, 이 클래스들은 next() 혹은 next(Token token)을 오버라이드하고 있는데 (이젠 next(Token token)뿐 이겠지만..) 이 next(Token token) 클래스에서 자기들이 할 일을 하게 되는 것이다.

예를 들어서 가장 먼저 실행되는 동의어 색인을 하는 클래스의 next() 메서드를 보면

public Token next() throws IOException {
  if (synonymStack.size() > 0) {
      return (Token) synonymStack.pop();
  }

  //input은 GSKoreanStopWordFilter
  Token token = input.next();
  if (token == null) {
   return null;
  }

  addAliasesToStack(token);
    return token;
 }

이 녀석은 동의어를 동의어 색인 파일로부터 추출하여 stack에 넣어두는데, 그 stack에 아무것도 없으면 (즉, 해당 단어에 대한 동의어가 더 이상 존재하지 않으면) input.next()를 실행하게 된다. 위에서 봤듯이 여기서 input은 이 SynonymFilter 클래스의 생성자에 파라메터로 넘어왔던 tokenStream이 되고 이 경우에 그 TokenStream은
GSKoreanStopWordFilter 가 되는 것이다. 각각의 필터들은 이처럼 자신이 할 일을 하고 더 이상 해당 Token(단어)에 대해 할 일이 없을 경우에는 다음 필터의 next() 메서드를 호출하도록 되어있다.

그래서 결국 Tokenizer의 next()까지 호출되게 되고 이 Tokenizer는 지정된 로직에 따라 하나의 토큰을 잘라내어 다음 필터에게 넘겨주고, 필터는 그 토큰으로 작업한 후 다시 그 토큰을 다음 필터에게 return 해주는 방식으로 되어 있는 것이다. (이쯤에서 위에 작성해놓은 루씬의 StopFilter 소스 분석 한 것을 봐도 좋을 듯..)

즉, 어느 하나의 filter에서라도 null을 리턴해 버리면 더 이상 동작을 하지 않게 되는 것이다.


PS. Analyzer의 저런 모양새는 루씬 1.4.X 버전에서 사용되던 방식인듯 합니다.
실제 이후의 Analyhzer는 모양이 많이 다르더군요. ^^
작동되는 방식은 비슷할 것 같습니다만 나중에 그것도 다시 정리해보겠습니다.