FilteringTokenFilter에 대한 포스트입니다.


4.0부터 처음 나온 필터 클래스는 아닙니다만.. (3.1 부터 나온 것 같습니다.)

제가 원래 Token의 제거를 (불용어 같은 케이스) TokenFilter를 상속받아서 직접 구현해서 쓰고 있었는데

이번에 4.0으로 전환하려고보니 이 filter 클래스가 눈에 띄워서 보게되었습니다.


TokenStream이 가지고있는 Attribute의 구현 클래스중에 PositionIncrementAttribute가 있습니다.


이것은 Token화되어 나온 키워드의 위치 증가를 보여주는 속성인데요..

예를 들어서 "자바 개발자 장용석" 이라는 문장을 공백을 기준으로 키워드 추출한다고 했을 때

기본적인 PositionIncrementAttibute의 값은 


자바 1, 개발자 1, 장용석 1


이 됩니다.


그런데 여기서 길이가 2이하인 키워드는 키워드 추출에서 제거시키는 Filter가 있다고 가정하면

2가지의 경우가 있을 수 있습니다.


개발자 1, 장용석 1

개발자 2, 장용석 1


제거되는 키워드를 위치정보 증가에 포함을 시킬 것인지 아닐 것인지에 따라서 위와 같은 값을 갖는데요..

일반적인 키워드 검색에서는 크게 의미가 없겠지만, 키워드간의 위치정보를 기반으로 검색을 하는 경우에는 

중요한 속성이 됩니다.


아무튼, 이런 형태의 Token Filtering을 구현 할 때 사용하는 필터 클래스입니다.


accept라는 메서드를 Filtering 조건에 맞게 구현해주고, 생성자 파라메터로 위의 위치정보 처리를 어떻게 할 것인지

boolean 값으로 지정해주면 알아서 해줍니다. TokenFilter 클래스를 상속하고 있고요... 예전에는 이걸 increment 메서드에서 다 구현을 해주었었거든요...





luceneKoreanAnalyzer에서도 이 Filter를 사용하도록 수정해야겠네요... 소스가 많이 간단해지네요..


샘플소스 : https://github.com/need4spd/aboutLucene

Posted by 용식
이래저래 공부도 해볼 겸
aboutLucene의 예제 소스를

https://github.com/need4spd/aboutLucene

으로 repository를 변경하였습니다.

Posted by 용식
앞선 포스트에서는
Analyzer의 기본이 되는 Tokenizer를 구현하여 보았습니다.

다시 한번 말씀드리면 Analyzer
Reader - Tokenizer - TokenFilter - TokenFilter - TokenFilter...
이런식으로 1개의 Tokenizer와 N개의 TokenFilter로 구성이 되어있습니다.
여기서는 그 TokenFilter 중 하나를 구현해보려 합니다.

앞에서 여러개의 Filter를 말씀드렸었습니다.

기본적으로 "어미사전"으로부터 어미를 제거하는 StemFilter
"동의어 사전"으로부터 동의어를 추출하는 SynonymFilter
"명사사전"으로부터 명사를 추출하는 NounExtractFilter
"불용어사전"으로부터 불용어 처리를 하는 StopFilter
"복합명사사전"으로부터 주어진 키워드를 사전에 정의된 명사들로 추출하는 CompoundsNounExtractFilter

등이 있는데요..
이번 프로젝트에서는 불용어제거 필터와 명사사전으로부터 명사를 추출하는
NounExtractFilter 그리고 동의어를 만들어내는 동의어 필터를 만들어보겠습니다.

이 두개 필터의 구현을 보시면 다른 원하시는 필터도 얼마든지 구현이 가능하실 것이라고 생각됩니다.

심플하게 만들어볼게요..~

우선 테스트케이스를 만들어보겠습니다.

 
DevysStopFilterTest.java
여기서 제거 할 불용어는 the와 . 입니다.
the는 영어 문장에서 가장 일반적으로 불용어 리스트에 들어가는 단어입니다. (a와 an과 함께..)
그리고 앞서 만든 Tokenizer에서 "."이 추출되는 것을 보셨을텐데요
이 "."을 이 StopFilter에서 제거해보도록 하겠습니다.

 
DevysStopFilter.java
로직은 생각보다 간단합니다.
Filter들은 모두 TokenFilter를 상속받고 마찬가지로 TokenStream의 추상메서드는 incrementToken메서드를
구현해야 합니다. 여기서는 불용어 리스트를 그냥 List로 선언하였고, 생성자에서 initStopWord메서드를 통해서
우리가 불용어로 설정한 "the"와 "."을 등록하여주고 있습니다.

실제로는 이 사전을 원하시는 방법으로 관리하시면 되겠죠.

특이한것이 enablePositionIncrements 이런 필드 데이터가 있습니다.

이것은 불용어를 제거한 후 나오는 Token에 대해서
위치정보를 불용어를 포함하여 설정 할 것인지 아니면 아예 불용어가 없던 상태로 생각하여
설정 할 것인지에 대한 flag 값입니다.

예를 들어서 아래와 같은 문장이 있다고 해보겠습니다.

"나는 the 개발자다"

"the"가 불용어라고 했을 때 enablePositionIncrements가 true인경우
"나는" : 1
"the" : 제거
"개발자다" : 2
이 됩니다. 이전 추출된 키워드와의 위치정보가 2이 되는 것이구요..

false인 경우에는
"나는" : 1
"the" : 제거
"개발자다" : 1
가 됩니다. 불용어 "the"는 제거되었지만 그 다음 키워드인 "개발자다"의 위치정보는
"the"를 기준으로하여 설정되게 됩니다.

이것은 나중에 검색식에서 키워드의 위치정보를 사용하여 검색하여야 할 때
필요한 옵션입니다. 루씬에서는 일반적으로 phraseQuery라고.. 합니다.
제가 만들어나갈 Analyzer에서는 사용되지 않기 때문에 자세한 내용은
책을 보시면 좋을 것 같습니다.

다만, 이런식으로 Filter에서는 필요한 속성에 대한 조절을 할 수도 있다는 것을 보여드리기 위하여
코드에 삽입하였습니다.

전체적인 로직은 간단합니다.
Tokenizer로부터 받은 결과를 가지고 불용어 리스트와 비교하여 불용어 사전에 등록된 키워드이면
무시하고 한번 더 loop를 돌리고 불용어 사전에 없는 키워드일 경우 true를 리턴합니다.

그렇게 되면 이 다음 Filter에서도 마찬가지로 tokenStream.incrementToken메서드의 리턴값이 true일 때에 대해서만
작업을 하게 될 것이므로 불용어가 추출되지 않도록 할 수 있는 것 입니다.

https://github.com/need4spd/aboutLucene
에서 체크아웃 받으 실 수 있습니다. 
Posted by 용식
그러면
이제 루씬에서의 Analyzer에 대해서 살펴보도록 하겠습니다.

이제부터 정말 작성하기가 조심스러워지네요... 저도 루씬을 잘 이해하고 있는 상태는 아니라서.... ㅎㅎ 
더군다나 제가 공부하며 만들었던 루씬 버전이 2.4 정도 버전인데 현재 3.3 버전은 기본적인 개념은 비슷하겠지만
내부적은 구현 방식은 완전히 바뀌었더라구요...

앞으로 내부적인 루씬 소스에대한 설명보다는 주로 사용법/커스터마이즈 방법을 소개해드리게 될 것 같습니다. 

우선 Analyzer가 어떻게 사용되는지 보도록 하겠습니다.


AnalyzerUsageSampleTest.java
우선 분석 할 문장을 StringReader로 생성하고, 이 문장을 분석 할 Analyzer를 생성합니다.
여기서는 WhitespaceAnalyzer를 이용하였습니다.

StringReader stringReader = new StringReader("집에서 블로그를 작성합니다.");

Analyzer analyzer = new WhitespaceAnalyzer(Version.LUCENE_32);


루씬에서 Raw데이터를 분석하기 위해서 쓰여지는 타입이
StringReader입니다. Raw데이터가 웹페이지던, 혹인 DB에 있는 데이터던
StringReader타입으로 변환해주면 얼마든지 루씬을 사용하여 색인/검색을 할 수 있습니다.
Analyzer에는 많은 종류가 있습니다. 여기서 사용한 WhitespaceAnalyzer
문장을 "공백"을 기준으로 분리하여 키워드를 추출하는 Analyzer입니다.
이 외에도 KeywordAnalyzer, SimpleAnalyzer, StandardAnalyzer등 많은 Analyzer가 존재합니다.
대략 이름만봐도 어떻게 키워드를 추출 할지 짐작이 되실 것 입니다.
WhitespaceAnalyzer의 생성자에서 사용되는 VersionAnalyzer 내부적으로
Api를 선택적으로 사용하기 위한 방법으로 보입니다. 아마 Lucene 4.0 정도가 되면
전부 정리가 되지 않을까 싶네요. 여기서는 크게 신경쓰지 않겠습니다.
그리고 문장과 필드(여기서는 title)를 가지고 TokenStream을 생성합니다.

TokenStream tokenStream = analyzer.tokenStream("title", stringReader);

여기서 "title"은 이 예제에서는 사용되지는 않습니다.
그리고 TokenStream으로부터 3가지의 Attribute를 받아옵니다.

CharTermAttribute termAtt = tokenStream.getAttribute(CharTermAttribute.class);

PositionIncrementAttribute posIncrAtt = tokenStream.addAttribute(PositionIncrementAttribute.class);

OffsetAttribute offsetAtt = tokenStream.addAttribute(OffsetAttribute.class);


바로 CharTermAttribute, PositionIncrementAttribute, OffsetAttribute 입니다.
이 3가지는 사실 루씬에서는 실제로 Token이라는 클래스가 사용되며 이 Token이라는 클래스안에 필드데이터로 들어있던 값들 입니다.

다만 3.X부터는 내부적으로 Token이 아니라 위 Attribute를 사용하도록 전체적으로 수정이 된 것 같습니다. 물론 Token 클래스도 여전히 존재합니다.
 
CharTermAttribute은 추출된 키워드를,
PositionIncrementAttribute 는 키워드가 바로 이전에 추출된 키워드로 부터 얼마나 떨어져있는지를,
OffsetAttribute 은 키워드가 문장에서 어느 위치에 있는지를 나타내는 속성입니다.

그리고 이 속성들은 모두 TokenStream이 상속하고 있는 AttributeSource 클래스에
속한 속성들입니다. 이후 AttributeSource라고 말씀드리면 이런 모든 속성들을 가지고 있는
클래스라고 생각해주시면 좋을 것 같습니다.
 
특히 CharTermAttribute를 대량의 데이터를 색인 할 때 리소스 활용과 속도를 위해서
char[]를 사용하여 재사용성을 최대로 높이고 있습니다.

위 3가지 속성은 색인/검색에 있어서 상당히 중요한 속성입니다.

CharTermAttriute는 추출된 키워드 그 자체를 나타내기에 당연히 중요한 속성이고
PositionIncrementAttribute는 검색을 할 때 "나이키와 운동화의 사이에 단어가 3 이하인 문장" 이러한식으로
검색을 할 때 사용됩니다.
OffsetAttriute는 제가 사용 했을 때는 검색된 결과에서 Match된 부분을 Highlight하여 보여줄 때
사용되었었습니다. 이 부분을 잘 이해하고 있어야 동의어에 의한 검색시에도 제대로 Match된 키워드에 Highlight하여 보여 줄 수 있습니다.

결국 Analyzer가 하는 역할은 색인 및 검색 대상이 되는 원문으로부터 키워드를 추출하고,
검색과 부가 정보 표시에 필요한 각종 속성들을 생성하여 넘겨주는 역할을 한다라고 볼 수 있을 것 입니다. 

더 자세한 내용은 책을 참고하시면 좋겠죠. ^^

그 후 TokenStream으로부터
추출되어 나오는 키워드가 없을 때 까지 loop를 돌면서
각 속성의 값을 추출하는 형식입니다.

-- 결과 --

text : 집에서

postIncrAttr : 1

startOffSet : 0

endOffSet : 3

text : 블로그를

postIncrAttr : 1

startOffSet : 4

endOffSet : 8

text : 작성합니다.

postIncrAttr : 1

startOffSet : 9

endOffSet : 15


이제 대략적인 사용법이 눈에 들어오시나요?
이렇게 TokenStream으로부터 추출되는 Attribute들을 사용하여 키워드를 색인하고 검색하는 것 입니다.

앞으로 Token이라는 것은 "키워드","위치정보"등이 포함된 데이터라고 생각해주시면 됩니다.
(위 3가지 속성의 합이라고 생각해주셔도 됩니다.) 
 

실제로 2.X 버전까지는 Token이라는 클래스를 TokenStream으로부터 받아서 사용하였지만
3.X로 올라오면서 Token을 사용하지 않고 위 예제처럼 TokenStream으로부터 필요한 Attribute를 얻어서
사용하는 방식으로 변경되었습니다.


Analyzer는 데코레이터 패턴으로 구현이 되어있습니다.
Analyzer는 크게 TokenizerTokenFilter를 사용하여 구성되어 있고
이 두 클래스는 모두 TokenStream을 상속하여 구현되어 있습니다. 실제 소스를 열어보시면 더 많은 클래스들이 관여하고 있고
3.X 버전에서 보면 구현하는 형태도 많이 변경되었지만 기본적인 개념은 TokenStream 클래스를 사용한 데코레이터 패턴입니다.

Tokenizer는 문장을 읽어서 자기가 가진 룰대로 문자를 잘라 Token으로 만들어내는 역할을하며,
이 
Tokenizer의 결과를 TokenFilter가 받아서 사용하고 또 그 결과를 TokenFilter가.. 어쩌구 저쩌구..
이런 형태로 구현이 되어있습니다.

new TokenFilter(new TokenFilter( new Tokenizer()));

//3.X에서는
TokenStreamComponents를 이용하여 구현 형태를 다소 다르게 하고 있습니다.
Tokenizer source = new Tokenizer();
new TokenStreamComponents(source , new TokenFilter(new TokenFilter(source)));

왜 이런형태가 되었는지는 저도 좀 살펴봐야 할 것 같네요..  


당연히 제일 안쪽에는 Tokenizer가 들어가야 할 것 입니다. 기본적으로 문장으로부터 Token이 추출되어야
Token을 가지고 Filter가 일을 할 수 있을테니까요.

실제로 불용어 리스트를 가지고 추출되는 Token이 해당 불용어와 일치하는 경우
Token을 버리는 StopAnalyzer는 아래와 같이 작성되어 있습니다.

final Tokenizer source = new WhitespaceTokenizer(matchVersion, reader);

return new TokenStreamComponents(source, new StopFilter(matchVersion, source, stopwords));


new TokenStreamComponents는 크게 신경쓰지마세요. 결국 우리가 사용하게 되는 것은
TokenStream입니다. 위 예제에서 analyzer.tokenStream()은 내부에서 결국
new TokenStreamComponents().getTokenStream()으로 연결됩니다. StopFiltersource를 생성자 파라메터로
받고 있는 것을 보실 수 있습니다.
위 예제에서 analyzer.tokenStream() TokenStreamComponents.getTokenStream()에 의해서
StopFilter
return됩니다. 따라서 결국 tokenStream.incrementToken() StopFilter incrementToken()
호출 되고 이 메서드 내부에서 StopFilter가 생성자 파라메터로 받은 source 즉, Tokenizer incementToken()
메서드가 선 호출되어 결과를 받아 처리하는 형태로 되어 있습니다. 

Tokenizer는 문장에서 기본적으로 키워드를 추출해내는 룰을 가지고 있습니다.
WhitespaceAnalyzerWhitespaceTokenizer를 사용하여 키워드를 추출하도록 되어 있고
WhitespaceTokenizer는 공백을 기준으로 하나의 Token를 생성하여 넘겨주도록 되어있습니다. 
즉, Tokenizer를 직접 구현하면 문장에서 추출되는 Token을 우리가 원하는대로 만들어 낼 수 있습니다.

단순히 공백이 아니라, 
 

mp3 -> [mp] + [3],

ipad사용자 -> [ipad] + [사용자]


등으로 한글과 영어/숫자를 분리하여 Token으로 추출해
낼 수도 있습니다.
잠깐 다른 이야기를 하면 영어,숫자와 한글은 기본적으로 분리를 하는 것이 좋기는 하지만 "넘버3" 같은 영화제목을 생각하면
붙이는 것이 맞을 수도 있습니다.

그래서 앞선 포스트에서 말씀드렸듯이 비지니스와 언어등에 따라서 추출되는 방법이 다 달라질 수 있다는 것 입니다.

그리고 TokenFilter가 있습니다. 이 TokenFilterTokenizer를 감싸는 형식 (데코레이터 패턴이라고 말씀드렸었죠?)으로
사용됩니다. 일반적으로 Tokenizer로부터 추출 된 Token에 뭔가 추가적인 작업을 하는 형태로 구현이 됩니다.
즉, 위 예제 코드에 있는 문장의 경우 공백을 기준으로 "집에서" 라는 키워드가 하나의 CharTermAttribute로 추출된다고 하면
여기서 "에서"라는 어미를 제거하여 최종적으로 "집"이라는 키워드를 추출한다던가 "집에서"라는 키워드 자체를
불용어로 등록하여 아예 아무런 키워드를 추출하지 않도록 하는 작업등을 TokenFilter가 하게 됩니다.

그러면 여기까지의 내용을 가지고 앞으로 우리가 무엇을 만들어야 할지 한번 살펴보겠습니다.
우선 주어진 문장으로부터 Token을 추출해야 합니다. Tokenizer를 구현해야 합니다.
하지만 공백을 기준으로 Token을 추출하면 한글에서는 큰 가치가 없는 Token일 경우가 많습니다.
따라서 우리는 이 Token으로 부터 의미있는 키워드를 추출해야 합니다.
Tokenizer에서 공백을 기준으로 Token을 만들어 넘겨주면 키워드를 추출하는 작업은 TokenFilter를 사용해서 진행합니다.
저희는 추가적으로 공백 + (한글과 영어/숫자)를 분리하여 넘겨주도록 해보겠습니다. 
기본적으로 "어미사전"으로부터 어미를 제거하는 StemFilter
"동의어 사전"으로부터 동의어를 추출하는 SynonymFilter
"명사사전"으로부터 명사를 추출하는 NounExtractFilter
"불용어사전"으로부터 불용어 처리를 하는 StopFilter
"복합명사사전"으로부터 주어진 키워드를 사전에 정의된 명사들로 추출하는 CompoundsNounExtractFilter
등을 생각해 볼 수 있겠습니다.
각각의 Filter들은 실제로 구현을 해보면서 Analyzer를 완성시켜보도록 하겠습니다.
가장 간단한 형식으로 구현 할지 모든 내용을 다 담아서
구현을 할지 아직 결정을 하지 못 했네요...
설명을 어떻게 해야할지도 중요한 문제라서 모든 내용을 다 담아서 전부 구현을 하게 되면.. 그것만으로도
글의 분량이 어마어마해질 것 같아서... 고민이네요...

포스트에 사용된 예제는https://github.com/need4spd/aboutLucene에서 체크아웃 받으 실 수 있습니다. 
Posted by 용식