루씬의 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 용식
루씬 한글 Analyzer  복합명사 추출 로직을 거의.. 확정하였습니다. (도대체 몇개월 만인지...ㅠㅠ)

일단 구조 자체는 Tokenizer에서 Token 추출 로직을 좀 만져주고..

나머지는 Filter Class에서 처리하는 방식입니다.

동의어 Filter만 적용하고

불용어나 어미제거 필터는 일단 만들어 놓고 적용은 하지 않기로 했습니다.

단순 명사추출이기 때문에 더 망가지더군요...;;;

사전 기반의 명사 추출입니다.

문서 색인용 Filter와 사용자 입력 키워드 분석용 Filter를 따로 만들었습니다.

두개의 로직이 약간 다른데요

일단 문서 색인용은

1. Token을 끝까지 탐색하여, 사전에 있는 가장 긴 명사를 추출
2. Token을 순차적으로 탐색하며, 명사를 추출
3. Token그 자체를 Return


이렇게 작동 됩니다.

예를 들어보면..

사전에
"검색" "엔진" "검색엔진" "개발자" "그룹"
이라는 단어들이 있다고 하고

[검색엔진개발자그룹] 이라는 Token을 집어 넣는다고 할때

1번에 의해서 Token을 끝까지 탐색하여
[검색엔진]을 추출합니다. 그리고 그 다음 index인 [개]부터 다시 탐색을 합니다.
그리고, 다음 긴 명사인 [개발자], 같은 로직으로 [그룹]을 추출합니다.

그리고 2번에 의해서 Token을 순차적으로 탐색하는데
[검색]이 우선 추출되고 그 다음 [엔]부터 탐색하여 [엔진]
그 다음은 [개]부터 탐색하여 [개발자] [그룹]
이런식으로 탐색을 하게 됩니다.

3번에 의해서는 [검색엔진개발자그룹] 이 자체 Token을 그대로 리턴합니다.

[검색],[검색엔진],[개발자],[그룹],[엔진],[검색엔진개발자그룹]

그 다음 동의어 필터에서 각 단어의  동의어가 존재하면
동의어를 같이 색인합니다.

이렇게 하면 중간 중간 겹치는 Token이 발생하는데 일단 이건 Set을 이용해서 중복단어를 처리하고 있습니다..

형태소 분석까지 겸하면 좋겠지만... 국어를 그렇게 잘 하지 못 합니다 -_-ㅋ

키워드 분석용 필터는 1번의 로직만을 적용합니다.

15평형 같은 단어가 만약 사전에 [평형]만 들어가 있다면

[15][평형] 으로 분리가 되는데 , 많은 사람들이 15평 이라고 검색을 하면 검색이 안 되는 경우가 존재합니다.

이것은 동의어나 그냥 사전추가를 통해 해결하기로 했습니다.

이제 문제는 튜닝인듯 합니다. 상품명을 대상으로 약 130만건을 색인하는데 2000초 정도가 소요되더군요 (optimize 시간 포함, 컬럼 20개, 쿼리 시간 포함). 좀 더 빨랐으면 하는데.. 잘 모르겠네요.

일단 이정도로 작성을 할 예정입니다..

Tokenizer에서 현재는 숫자/영어/한글 을 각각의 토큰으로 분리해 내고 있는데
숫자와 영어는 하나의 토큰으로 묶어서 나오도록 하는 것도 어떨까.. 고민중입니다..

나중에는 대상 데이터를 whitespaceanalyzer로 색인해서 Text로 뽑아놓고
거기에 존재하는 명사들을 수작업으로 뽑아내서 사전 보강하는 작업도 해야되겠죠..^^;;;


10월 17일 추가.
그리고 어미사전과 조사사전을 활용해서 일단 스페이스 단위로 Token을 만들어내고
여기서 어미제거와 조사를 제거해서 1차적으로 명사(라고 가정되는) 상태로 만들고 이것을 바탕으로
현재 데이터에서의 명사를 명사사전에 추가하는 방법으로
사전을 보강하는 방안이 괜찮을 것 같습니다.

Posted by 용식

일단 이름 Term을 사전에서 찾아내고 그 다음에 하는 일이

명사를 추출하는 것이다.

어케 보면 별거 없는데 이따 참으로 고민을 많이했다.

일단 명사 사전이 있다 치고, 과연 어떻게 만족스러운 명사 추출을 할 것인가...

대표적인 예가..

"검색엔진개발자" 라는 단어가 있고 사전에는

"검색" , "엔진", "개발자" 라는 단어들이 있다고 하자..

원하는 결과는

"검색엔진개발자" , "검색", "엔진" ,"개발자", "검색엔진", "엔진개발자" 이런식의 결과가 나오기를 원했다.

처음에는 앞에서 부터 하나씩 서치해가는 방식이었는데..

즉..


검색
검색엔진
검색엔진개...
.
.

색엔
색엔진
색엔진개..

이런식.. 이게 너무 하나의 단어에 대해서 loop를 많이 돌고 결정적으로 위의 경우에서

사전에 "검" 이라는 단어가 있다면 전혀 상관없는 단어가 나올 수 있다는 문제가 있었다.

그리고 "서핑클럽" 의 예도 나올 수 있었다.. 핑클로 검색하면 서핑클럽이 나오는..

그래서 해본 두번째 방식이 가장 큰 단어부터 찾아보자였다.

즉, "검색엔진개발자"

검색엔진개발
색엔진개발자
검색엔진개
엔진개발자
.
.
이런식으로 해봤더니 마찬가지로 불필요한 단어들이 많이 나오는 경우가 있었다.

그래서 한 것이.. "순차적으로 찾되 가장 길게 매칭되는 단어 추출" + "추출된 명사 합치기" 였다.

즉..


검색
검색엔
검색엔진

이런식으로 탐색을 하지만, 사전에 "검"이 있다고 검을 추출하는 것이 아니라
한번 더 탐색을 해봐서 (여기서는 "검색") 사전에 맞는 것이 있으면 그 단어를 추출 하는
방식이었다.

위의 경우에서는 "검"이 사전에 있지만 그 다음 단어인 "검색"이 사전에 존재하고

"검색엔"이라는 단어는 사전에 없기 때문에 "검색"을 추출하는 방식이었다.

"엔진" 마찬가지로 그렇게 추출이 되고 그러면 4개의 term이 추출되는데

"검색" "엔진" "개발" "자" (혹은 "개발자")

이 term을 합친다. 그래서 위 term 집합에 아래의 term 목록이 더해진다.

"검색엔진" "엔진개발자"

그리고, 나는 의도적으로 사용자가 입력한 단어 그 자체 혹은, 탐색 대상이 되는 단어 그 자체 (위에서는 검색엔진개발자)를 그대로 리턴하는 로직을 추가했기 때문에
최종적으로 아래와 같은 term이 추출되게 된다.

"검색엔진개발자" "검색" "엔진" "개발자" "검색엔진" "엔진개발자"

여기에 추가로 구현하려고 하는 것은 Token의 Type을 주어서 그 Type에 따라서
가중치를 부여하는 방안도 생각하고 있다.

아무튼, 위와 같은 로직으로 구현을 해보았다.

(물론 사용자가 입력한 키워드에서 복합명사를 추출하는 과정은 좀 더 단순하다. 사용자가 입력한 키워드가 "검색엔진개발자" 라면, 단순히 "검색" or "엔진" or "개발자"
이 키워드로 검색을 하면 될거 같다라는 생각이기 때문이다.)

그리고 의도적으로 입력된 단어 그 자체를 리턴하는 것은 만약

"아키텍처에서" 라는 문장이 들어오면

아키텍처 라는 키워드도 추출하겠지만, 저 문장 자체로 검색 할 경우

즉, "아키텍처에서" 라는 키워드로 검색을 하는 경우도 있을거라 생각을 해서..

그런 로직을 넣어보았다.

현재 Token의 타입은 WORD,NOUN,NAME 정도를 사용하고 있는데,  색인 할때 가중치를 Term에 부여하는 방법을 아직 찾지 못 하여, 검색 질의 키워드가 들어왔을때
Query를 만드는 과정에서 각 Term에 가중치를 주는 방식을 사용하고 있다.

이부분은 차후에 위에서 언급한대로 Type에 따른 색인시에 가중치를 다르게 주는 방안으로 꼭 구현을 해보고 싶다.

소스 전부를 공개하지는 못 해도..(창피하다. -_-)

위 클래스를 작성하기 위해서는 TokenFilter를 상속받아 next() 메서드를 오버라이드 하면 된다.

이 필터 역시 루씬 인 액션 책의 소스가 참고가 되었는데

불용어 Filter같은 경우 while문을 사용해서 불용어가 들어왔을 경우 무시하고 다음 Token을 가져오는 로직을 태우지만 이 클래스 같은 경우는

Token token = input.next(); 로 받아서
token이 null이면 끝내버리고

위 로직을 구현한 메서드 (혹은 클래스)를 사용하여 복합명사를 stack등에 넣어 놓은 후

그 스택에 있는 token을 내보내는 방식으로 구현하였다.

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

 if(token == null) {
   return null;
  }

 seperator(token); //명사를 분리하여 stack에 push.

 return token;
}
}

Posted by 용식

CharTokenizer에 이어서 한글 Analyzer를 만들기 위해 작성한 사전 클래스입니다.

이 클래스는 루씬에서 제공되는 클래스는 아니고 제가 필요에 의해서 작성한

클래스입니다.

기본적인 틀은 루씬 인 액션에 있는 소스를 참고하였고 세부적인 내용은

제가 작성을 하였습니다. 참고해주시구요..


사전을 몇개 만들었다.

명사사전/ 이름사전/ 불용어사전/ 동의어사전/ 어미제거사전

자..이렇게 해놓고 모든걸 적용시켜보니.. 일단 색인어 추출을 (not 형태소분석) 하는데 있어서 어미제거가 되어버리면 , 단어가 너무 많이 망가지는 현상이 발생하여
현재는 어미제거는 하지 않고 있다.

위 사전들을 적용한 클래스를 앞으로 하나하나 작성을 해볼텐데..

일단 기본이 되는 사전 클래스..

사전 클래스는 어렵게 구현하지는 않았다. (못했다.-_-)

일단 기본적으로 웹에서도 관리가 되도록 하기 위해서

addWord, removeWord, findWord 3개의 메서드를 가진 추상 클래스를 작성하고

나머지들은 위 클래스를 상속해 구현하는 방식으로 작성을 하였다.

사전을 읽어들이는 방식은

이름사전이나 명사사전은 HashSet으로 파일을 읽어 들이는 방식으로 만들었고,

싱글턴 패턴을 사용하여, 사전 클래스를 불필요하게 로딩하지 않도록 하였다.

(사전의 양이 많아질 수록 부담이 되기 때문에..)

단어 추가 같은 경우는 일단 메모리에 존재하는 HashSet에서 데이터를 확인하고

단어가 없으면 Hash에 단어를 추가 후 파일에도 추가하는 방법으로 구현하였으며

삭제 같은 경우는 Hash에서 단어를 삭제 후

이 Hash를 다시 파일로 전부 Write하는 방식으로 구현하였다.

그리고 이 로딩된 Hash를 참조 할 수 있는 get 메서드를 구현하여 나중에 설명 할

필터 클래스에서 이 사전을 가져다가 사용 할 수 있도록 하였다.

사전 자체도 그냥 txt로 만들어서 읽어오기 때문에 딱히 구현에 어려움이 있는

그런 클래스는 아니었다.


뭐 딱히 설명 할 게 있는 클래스는 아니지만..이런 클래스도 사용했다..라고 작성해두기 위함입니다.

Posted by 용식
커..

Analyzer하나 만드는게 장난 아닙니다.

전에 올렸듯이.. Tokenizer는 기본적으로 스페이스,특수문자,영어&숫자 와 한글 분리
로 토큰을 만들어 내는 방식을 고수하고 있습니다.

문제는 이제 저렇게 해서 나온 TokenStream을 받아서 어떻게 색인어를 추출하느냐..

마땅히 어디서 구할 사전도 없고

해서 정말 아는 것도 없는 놈이 막코딩을 하고 있습니다..ㅠㅠ

일단 기본적인 명사 사전을 가지고..

들어온 Token에서 명사를 추출해야 하는데.. 현재는 이런 방식을 쓰고 있습니다.

String target_term = "언어로배우는자바디자인패턴";

  for(int i=0; i < target_term.length(); i++) {
   for(int j=i; j < target_term.length() + 1; j++) {
     temp = target_term.substring(i, j);

     if(사전.contains(temp)) {
           추출;
      result.setPositionIncrement(1);
     }
   }
  }

대략 이런 방식입니다.

무식한 방법이죠.. 그런데 단어안에 있는 모든 명사를 뽑아내고 싶다 보니..
저런 방식으로 하게 되었네요..

자 이렇게 해놓고 보니..
"언어로배우는자바디자인패턴" 여기서..

제가 원하는 것은

"언어","자바","디자인","패턴","디자인패턴" 이정도이겠지만..

위 방식으로 검색을 해보면,,

"바디","어로","배우" 등의 단어가 사전에 있을경우 이 단어들도

추출이 되어버린다는 거죠... 먼 옛날 "핑클" 검색하면 "서핑클럽" 검색되듯이..


이것을 어떻게 해야 할지 막막합니다.

국어를 잘해서 형태소 분석을 하고 있는 것도 아니고..

막연하게 가중치(?)

가중치를 주려고 해도 뭔가 로직이있어야 할텐데.. "바디" 보다는 "자바" 가 훨씬

의미있는 term이다. 라는 것을 알아낼 수 있는 방법...


덕분에 계속 삽질 중 입니다..;;; ㅠㅠ
Posted by 용식