앞에서 AttributeSource에 Attribute들 (term, offset, position, type 등) set 해주면
그 뒤에 오는 Filter나 Tokenizer가 해당 Attribute를 사용 할 수 있다고 했습니다.
각각의 Filter와 Tokenizer는 모두 TokenStream을 상속하고 있고 이 TokenStream은
AttributeSource를 상속받고 있습니다.

이 Filter와 Tokenizer가 서로 데코레이터 패턴으로 물려있기 때문에
앞에서 set한 결과를 뒤에 따라오는 Filter와 Tokenizer가 사용 할 수 있는 것 입니다.
(Java의 stream 관련 클래스들을 생각하시면 됩니다.)

소스를 보면
보통 맨 앞단에서 들어오는 String을 잘라내는 역할을 하는 Tokenizer는 아래와 같이
사용 됩니다.


보시면 생성자에서
TermAttribute등을 addAttribute 메서드를 사용해서 등록하고 있습니다
이 메서드는 attribute가 있으면 있는 attribute를 리턴하고, 없으면 새롭게 attribute를 AttributeSource에 set해주는
역할을 합니다.
그럼 그냥 setAttribute를 해주면 되지 왜 저런 역할 (있으면 return, 없으면 add)을 하는 메서드가 필요할까요?
루씬에서 사용되는 필터나 Tokenizer는 사실 맨 앞이나 맨 뒤나 아니며 중간이던 어느 위치에서도
사용 될 수 있습니다. 때문에 저런 메서드가 필요한 것입니다.
하나의 필터가 맨 앞에서 사용 될거라고 해서 setAttribute 메서드를 사용해서 구현을 했는데 다른 Analyzer에서
이 필터가 3번째 4번째에서 사용될 수도 있기 때문입니다.

그리고 boolean incrementToken 메서드를 보시면 결과 값을 attribute에 set해주고 있는 것을 보실 수 있습니다.

그러면 이 결과를 받아서 사용하는
다른 Filter를 한번 보겠습니다. 지금까지 Analyzer를 구현하면서 본 Filter들은 크게 두개의 패턴을 가지고 있었습니다.

하나는 Tokenizer 처럼 하나의 결과를 받아서 그 결과에 직접 작업을 하는 필터입니다.하나의 Term을 만들거나 받아서
하나의 Term을 리턴하는 경우죠. 자세한 방법은 나중에 Analyzer에 대해서 얘기 할 때 자세하게 작성하겠습니다.
이 경우 따로 결과를 저장해 놓을 필요 없이 그냥 바로 "결과 Attribute를 넣어주면 됩니다.

또 하나는 동의어 추출이나 복합명사 추출 처럼 하나의 결과를 받아서 그 결과로부터 다른 추가적인 term을 추출해내는
필터입니다.
"노트북" 이라는 term이 들어오면, 이 노트북이라는 단어와 동의어로 설정 된 notebook,노트PC등의 term을 추가로 리턴해주는
필터입니다. 보통, List나 Stack에 결과를 담아두고 값을 하나하나 리턴하게 됩니다.
모양새는 서로 비슷합니다.

incrementToken 메서드는 이전 버전에서의 next 메서드와 같은 역할을 합니다.

그런데, 이 메서드의 return type이 boolean입니다. 그럼 어떻게 후속 필터들이 이 결과를 받을 수 있을까요?

첫번째 경우는 앞어 있는 Tokenizer 소스를 보시면 되고
두번째 경우인 추가적인 term를 만들어내는 필터를 보겠습니다.
Stemming 필터로써 어미 사전을 사용해 앞 필터에서 만들어낸 Term에서 어미를 제거하여 리턴하는 것입니다.
그런데 왜 stack을 사용하냐면 원본 Term도 리턴을 해줘야 하기 때문입니다.
이것도 나중에 Analyzer에 대해서 작성 할 때 상세히 알아보겠습니다.

아무튼 아래 stemming 필터를 보겠습니다.

생성자에서 Attribute3개를 addAttribute 해주고 있습니다.
만약, 앞선 Filter나 Tokenizer가 있다면 이 3개의 Attribute에는 앞선 Filter와 Tokenizer의 결과가
들어 올 것입니다.

그리고, AttributeSource save라고 선언된 것이 있는데 현재 input (즉, 앞선 Filter와 Tokenizer)를 cloneAttributes 메서드를 사용해서 Attribute들을 복사하고 있습니다. (사실은 AttributeSource 한벌이 더 생기는 거겠죠..)
왜 이게 필요할까요..

boolean incrementToken() 메서드를 보면

이렇게 되어 있는데요..
우선 #4를 보시면 앞선 필터에서의 결과가 없으면 역시 false를 리턴해서 결과 없음을 표시합니다.
#5 는 term을 받아서 그것을 stem 메서드를 사용해 어미를 잘라내는 부분입니다.
#6 #5에서의 결과를 가지고 어미가 잘려나갔으면
#7 현재의 State를 capture하고 있는데요 , 이 부분이 중요합니다. AttributeSource는 state라는 것을 갖습니다.
그리고 이 State는 captureState 메서드를 통해 snapshot을 저장해 둘 수도 있고
restore메서드를 사용해 다시 되살릴 수도 있습니다. 왜 이 부분이 필요하냐면...
앞선 필터에서 받은 Term이 "사랑해서"라고 가정했을 때 이 Stem 필터에서는 "사랑" 이라고 어미를 잘라낸 후
AttributeSource에 해당 Attribute를 set 할 것 입니다. (#9 번)

그렇게 되면 앞선 필터로 받았던 "사랑해서"에 대한 Attribute가 모두 사라지게 됩니다.

나중에 이 "사랑해서"라는 Term을 가지고 작업을 하려면 다시 이 것을 retore 메서드를 사용해서 처음으로 돌려놓아야 하는데
이를 위해서 앞에서 AttributeSource를 clone해서 save라는 한벌을 더 가지고 있었고, 이 save AttributeSource의 State를
Attribute가 변하기 전에 capture해 놓는 것입니다.

#10을 보면
이전 버전에서는 결과 Token을 Stack에 넣어두고 Token을 리턴했지면
여기서는 해당 Attribute들이 저장되어 있는 AttributeSource의 State를 Stack에 넣어놓습니다.
이 AttributeSource로부터 stem된 결과 Attribute들을 받아낼 수 있기 때문입니다.

실제로 #1 에서 stack의 사이즈가 0 보다 크면
#2에서 state를 받아내고
#3에서 그 State를 restore한 후 true를 리턴해주고 있습니다.

어차피 다음 필터에서도
생성자에서 addAttribute 메서드를 사용해서 Attriute를 받아내고 있기 때문에
이렇게 restoreState메서드를 사용하면 이 Stem 필터에서 결과로 뽑아낸 Attribute들을
AttributeSource로부터 받아 쓸 수 있게 됩니다.
Posted by 용식

예전에 작성해 놓은 Analyzer와 Filter 포스트는 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);
 }

이런 식이었는데 새버젼 소스를 받아서 열어보니 많이 바뀌었습니다.
아직 기존의 Analyzer를 새 버젼에 맞춰서 변경시키지는 못 했는데, 일단 어느 부분이 바뀌었는지 간략하게나마
알아봤으면 좋겠습니다.

간단한 모양을 하고 있는 StopAnalyzer를 열어보았습니다.

  public TokenStream tokenStream(String fieldName, Reader reader) {
    return new StopFilter(new LowerCaseTokenizer(reader), stopWords);
  }

기존에 사용하던 방식의 tokenStream 메서드도 있습니다. 버젼이 올라갔다고 이걸 그냥 없애버리면 아무래도 버젼을 업그레이드 하기 어렵겠죠.. ^^

그리고 새로운 메서드와 내부클래스가 눈에 들어옵니다.

  private class SavedStreams {
    Tokenizer source;
    TokenStream result;
  };

  public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException {
    SavedStreams streams = (SavedStreams) getPreviousTokenStream();
    if (streams == null) {
      streams = new SavedStreams();
      streams.source = new LowerCaseTokenizer(reader);
      streams.result = new StopFilter(streams.source, stopWords);
      setPreviousTokenStream(streams);
    } else
      streams.source.reset(reader);
    return streams.result;
  }


reusableTokenStream 이 메서드인데요, 그냥 한번 훑어보니 뭔가 tokenStream을 저장해놓고 그것을 재 사용하는 방식을 쓰는 것 같습니다.

일단은 최상위 추상 클래스인 Analyzer에 정의 되어 있는 getPreviousTokenStream(); 메서드를 호출하여
뭔가 예전의 TokenStream을 가져옵니다. 위 메서드는 아래와 같이 되어 있습니다.

 private ThreadLocal tokenStreams = new ThreadLocal();

  protected Object getPreviousTokenStream() {
    return tokenStreams.get();
  }

  protected void setPreviousTokenStream(Object obj) {
    tokenStreams.set(obj);
  }

ThreadLocal 이라는 녀석이 저의 짧은 지식으로는 수행되는 쓰레드별로 별도의 저장소를 만들어
쓰레드당 고유의 저장소에서 객첼르 저장하고 사용하는 방식을 제공하는 그런거로 알고 있는데 일단 이것은 좀 더 공부가 필요 할 듯 하다. ㅠㅠ

아무튼, 싱글턴 이라는 것을 기억해두고 계속 진행해 보면, 그렇게해서 저장되어 있는 (공유되고 있는) SavedStream이 없을 경우에는 새로운 SavedStream을 생성 시킵니다.
그리고 tokenizer를 SavedStreams.source에 set하고
SavedStreams.result 에 TokenStream (FilterClass들) 들을 set 한 후 1.4.X 버젼과 비슷한 방식으로 사용을 하게 됩니다. 그리고 마지막에는
setPreviousTokenStream(streams);
메서드를 통해 생성했던 SavedStreams 객체를 ThreadLocal에 넣어놓습니다.

그리고, 만약 streams != null 이면
streams.source.reset(reader); 를 통해서 contents를 reset하고
TokenStream을 리턴합니다.

쓰레드가 살아있는 동안 TokenStream을 새로 생성 할 필요없이..
다시 가져와 사용 할 수 있다.. 라는 정도로 이해하면 무리가 없을지 잘 모르겠습니다. ^^
파싱하려는 내용이 변경되어도 streams.source.reset(reader); 을 통해서 contents를 reset해주고 있기
때문에 문제는 없을 것 같구요.


그 다음에 살펴 볼 부분이 next(final Token token) 메서드입니다.
2.4.0으로 넘어오면서 final 이 붙어버렸습니다.

일단 쉬운 비교를 위해서 예전과 소스를 비교해보겠습니다.

예전:

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;
  }


 

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

bold 처리 된 부분이 바뀐 부분인데요
파라메터가 final로 변경되면서 그 값을 변경하지 못 하기 때문에
내부적으로 nextToken이라는 로컬 객체를 하나 더 생성하여 사용하고 있습니다. reusableToken은 그냥 다음 필터클래스나 토크나이저 클래스에 넘어갈 뿐입니다.

한가지 궁금한 것은.. reusableToken이라는 것이 어디에 사용되는가 하는 건데요...;;;

사실 각각의 필터에서 조작하고 작업하는 대상이 되는 Token은 nextToken일 것인데..
reusableToken은 무엇 때문에 final로 선언되어 파라메터로 넘어가고 있는건지 아직은 잘 모르겠습니다..

나중에는 Tokenizer에서 추출된 Term과 포지션 정보를 set해서 넘겨주는데 사용하고 로직을 보면 새로운 Token 객체의 생성없이 계속 처음에 생성된 그 Token을 재사용하면서 쓰고 있긴한데..

그외 다른 목적에 대해서
나중에 알게 되면 다시 정리하거나.. 혹시 알고 계신분이 있으시면
댓글로라도 답변을 좀 부탁드리고 싶네요. ^^
Posted by 용식

루씬의 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는 모양이 많이 다르더군요. ^^
작동되는 방식은 비슷할 것 같습니다만 나중에 그것도 다시 정리해보겠습니다.

Posted by 용식