본문 바로가기

Lucene

[about Lucene] 루씬으로 검색엔진 개발하기 - Analyzer (2) -

그러면
이제 루씬에서의 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에서 체크아웃 받으 실 수 있습니다.