본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 6. 분석기(1) - 활용 및 구조

by 잭피 2020. 12. 15.

분석기는 여러 단계를 거쳐 텍스트에서 텀을 분리합니다

분석기 활용

루씬은 텍스트를 텀으로 분리해야 할 필요가 있으면 분석기를 사용합니다

분석기는 색인하거나 검색할 때 사용합니다

 

상황별로 분석기를 어떻게 사용하는지 알아봅시다

 

예제1) "The quick brown fox jumped over the lazy dog"

WhitespaceAnalyzer : [The] [quick] [brown] [fox] [jumped] [over] [the] [lazy] [dog]

SimpleAnalyzer : [The] [quick] [brown] [fox] [jumped] [over] [the] [lazy] [dog]

StopAnalyzer : [quick] [brown] [fox] [jumped] [over] [lazy] [dog]

StandardAnalyzer : [quick] [brown] [fox] [jumped] [over] [lazy] [dog]

 

색인 과정에서 이렇게 분리한 토큰에 필드 이름을 더해 텀으로 생성됩니다

이렇게 색인된 텀에 검색할 수 있습니다

만약 Field.Index.NOT_ANALYZED or Field.Index.ANALYZED_NO_NORMS 설정을 지정하면 분석기를 통하지 않고 전체 텍스트가 하나의 토큰으로 색인됩니다

 

예제를 하나 더 볼까요?

예제2) "XY&Z Corporation - xyz@example.com"

WhitespaceAnalyzer : [XY&Z] [Corporation] [-] [xyz@example.com]

SimpleAnalyzer : [xy] [z] [corporation] [xyz] [example] [com]

StopAnalyzer : [xy] [z] [corporation] [xyz] [example] [com]

StandardAnalyzer : [xy&z] [corporation] [xyz@example.com]

 

각 분석기에서 어떻게 처리하는지 알아보겠습니다

whitespaceAnalyzer

공백 문자 기준으로 토큰을 분리합니다

SimpleAnalyzer

알파벳이 아닌 모든 글자 기준으로 토큰을 분리하고, 모두 소문자로 변경합니다

(숫자도 모두 제거합니다)

StopAnalyzer

SimpleAnalyzer와 비슷하지만 추가적으로 불용어를 제거해줍니다

기본 설정으로 영어의 불용어(the, a등)을 제거하지만, 원한다면 불용어 목록을 별도로 지정할 수 있습니다

StandardAnalyzer

루씬에 내장된 분석기 중 가장 섬세한 분석기입니다

텍스트를 분석해 회사 이름, 이메일 주소, 도메인 이름 등의 다양한 토큰을 인식하고 별도로 분리합니다

또한 토큰의 알파벳을 모두 소문자로 변경하고, 불용어도 제거하고, 특수 기호도 모두 제거합니다

 

색인 과정의 분석기

IndexWriter 인스턴스를 생성할 때 Analyzer를 지정합니다

Analyzer analyzer = new StandardAnlyzer(Version.LUCENE_30);
IndexWriter writer = new IndexWriter(directory, analyzer,IndexWriter.MaxFieldLength.UNLIMITED);

IndexWriter 클래스의 addDocument, updateDocument 메소드에 추가인자로 분석기 인스턴스를 지정할 수 있습니다 (해당 문서를 분석할 때만 사용하려면)

 

new Field("title", "this is the title", Field.Store.YES, Field.Index.ANALYZED) 텍스트를 분석하고 원문을 색인에 저장하는 필드 인스턴스를 생성합니다
즉, 원문을 색인에 그대로 보관하고, 분석기로 분석한 토큰을 색인해 질의에 해당하는 문서를 검색합니다
만약 Field.Store.NO로 설정하면 원문을 색인에 보관하지 않습니다

QueryParser와 분석기

QuerytParser 내부에서는 분석기를 통해 사용자가 입력한 텍스트에서 토큰을 추출하고 질의로 변환합니다

QueryParser parser = new QueryParser(Version.LUCENE_30, "contents", analyzer);
Query query = parser.parse(expression);

문장 전체를 분석기에 넘기지 않고

연산자나 괄호, 또는 범위, 와일드카드, 퍼지 검색 등의 표현식 문법을 모두 제외하고

검색어라고 판단되는 부분만 분석기에 넘깁니다

 

예를 통해 보겠습니다

예제) "President obama" +harvard +professor

총 3번 분석기를 호출합니다

1.President obama 분석

2.harvard 분석

3.professor 분석

파싱과 분석의 차이점

원본 문서를 읽어 의미 단위별로 텍스트를 뽑아주는 작업을 파싱, 또는 전처리라고 부릅니다

그리고 이런 텍스트를 각자의 필드에 넣고 색인하면 분석 과정을 거쳐 토큰으로 추출합니다

분석기 내부 구조

분석 과정을 이해하려면 분석기의 내부 구조를 봐야합니다

Analyzer 클래스는 모든 분석기 클래스의 최상위 추상 클래스이며, 입력 받은 텍스트를 토큰으로 변환합니다

토큰은 TokenStream 클래스로 표현합니다

Anaylzer 클래스를 상속받은 클래스는 tokenStream() 메소드를 구현해야합니다

public TokenStream tokenStream(String fieldName, Reader reader)

TokenStream 인스턴스를 통해 토큰을 하나씩 가져올 수 있습니다

 

예제) SimpleAnalyzer

public final class SimpleAnalyzer extends Analzer {
	
	@Override
	public TokenStream tokenStream(String fieldName, Reader reader) {
		return new LowerCaseTokenizer(reader)
	}

	@Override
	public TokenStream reusableTokenStream(String fieldName, Reader reader) {
		Tokenizer tokenizer = (Tokenizer) getPreviousTokenStream();
		if (tokenizer == null) {
			tokenizer = new LowerCaseTokenizer(reader);
			setPreviousToenStream(tokenizer);
		} else {
			tokenizer.reset(reader);	
		}
		return tokenizer;
	}
}

LowerCaseTokenizer 클래스는 먼저 글자를 판단합니다 (Character.isLetter())

모든 숫자나 기호를 기준으로 토큰을 분리하고, 모두 소문자로 변경합니다

 

reusableTokenStream 메소드는 색인 과정에 분석기의 처리 성능을 높일 수 있게 도와줍니다

앞서 생성한 동일한 스레드에게 동일한 TokenStream 겍체를 재사용하게 도와줍니다

(반드시 구현해야 할 필요는 없습니다)

 

Analyzer

setPreviousTokenStream(), getPreviousTokenStream()

스레드 로컬 저장소에 TokenStream을 저장하거나 불러옵니다

 

루씬에 내장된 모든 분석기는 reusableTokenStream 메소드를 구현합니다

토큰

텍스트에서 추출된 토큰은 분석 과정의 기본 단위입니다

색인할 때 지정된 분석기로 토큰을 추출하며, 중요한 몇 가지 속성도 색인에 함께 보관합니다

예제) "the quick brown fox"

토큰은 기본적으로 단어와 함께 몇 가지 메타 정보도 담고 있습니다

(메타 정보 : 전체 텍스트에서의 시작 및 끝 지점, 토큰의 종류, 위치 증가 값 등)

 

애플리케이션에서 이런 추가 정보를 비트 플래그로 설정하거나

byte 배열 형태로 적재 공간(payload)에 저장할 수 있습니다

 

시작 지점과 끝 지점의 경우는 하이라이팅 기능에서 유용합니다

토큰의 종류는 문자열로 지정하며 기본 값은 'word' 입니다

필요한 경우 토큰의 종류를 직접 지정하거나 종류에 따라 원하는 토큰만 골라낼 수 있습니다

토큰으로 분리하면 직전 토큰과의 상대적인 위치위치 증가값으로 기록합니다

(대부분 분석기는 위치 증가 값을 default 1로 유지합니다)

즉, 모든 토큰이 직전 토큰의 다음 위치에 존재한다는 뜻입니다

 

루씬 분석기는 플래그를 사용하지 않고 적재 공간(Payload)을 사용합니다

(필요한 경우 검색 애플리케이션에서 사용할 수 있습니다)

플래그 : 32비트 공간(int 변수에 저장)에 지정

적재공간(payload) : 플래그와 비슷하게 토큰마다 byte 배열의 값을 지정

토큰의 적재 공간을 활용하는 방법은 상당히 고급 주제입니다

토큰으로 텀 생성

색인 과정에서 텍스트 분석 후, 확보한 토큰을 텀의 형태로 색인에 추가합니다

토큰의 메타 정보 중 색인에 보관하는 값으로는 위치 증가 값, 시작과 끝 지점, 적재 공간의 내용 등이 있습니다

토큰의 종류와 플래그 값은 색인에 보관하지 않습니다 (분석 과정에서만 사용할 수 있는 값입니다)

위치 증가 값

위치 증가 값은 구문 질의나 스팬 질의를 검색하면서 필요합니다

(각 토큰이 얼마나 멀리 떨어져 있는가지를 파악)

위치 증가 값이 1보다 크다면 토큰 사이에 공간이 있거나 분석 과정에서 제거된 토큰을 의미합니다

경우에 따라 현재 토큰과 동일한 의미의 유의어를 토큰으로 추가하고,

현재 토큰과 같다는 의미로 위치 증가 값을 0으로 지정합니다

(위치 증가 값을 0으로 지정해두면 구문 질의에서 분석기가 추가한 유의어로도 검색 결과를 받을 수 있습니다)

 

TokenStream

필요한 시점에 토큰을 뽑아주는 기능을 가지고 있습니다

Tokenizer와 TokenFilter 2개의 클래스를 가집니다

TokenFilter는 컴포지트 패턴에 따라 다른 TokenStream을 담고 있는 형태입니다

Composite Pattern

객체들을 트리 구조로 구성하여 그릇 객체와 내용물 객체를 동일하게 취급할 수 있도록 만들기 위한 패턴입니다

TokenStream : Tokenizer와 TokenFilter의 상위 클래스(공통 인터페이스)

TokenFilter : 다른 TokenStream에서 이미 생성한 토큰을 받아와서 처리해주는 역할 (다른 TokenStream을 참조할 수 있음)

Tokenizer : 토큰을 생성해주는 내용물 객체 (TokenFilter 객체를 포함할 수 없음)

예제) "검색 API" → [검색] [API] → [검색] [api] → [ㄱㅓㅁㅅㅐㄱ] [api] → [ㄱㅓㅁㅅㅐㄱ] [API]

분석기 체인

Tokenizer를 사용해 텍스트에서 토큰을 생성하고,

여러 개의 TokenFilter를 통해 토큰을 변경한다

 

TokenStream 클래스를 구현할 때 토큰 버퍼를 적절히 사용해야합니다

저수준의 Tokenizer 클래스는 공백,숫자,기호 등을 만나 토큰을 잘라낼 때까지 글자를 버퍼에 담아둡니다

토큰을 추가해 리턴하는 TokenFilter 클래스는

받아오는 토큰과 추가로 준비한 토큰을 큐에 쌓아두고 한 번에 하나씩 리턴해야 합니다

 

루씬에 내장된 TokenFilter는 하나의 TokenStream에서 토큰을 받아와 처리합니다

하지만 TeeSinkTokenFilter는 받아온 토큰을 싱크라고 부르는 여러 개의 스트림으로 출력할 수 있습니다 (일부 분석 과정은 공유하지만, 일정 단계 이후부터 서로 다른 방법으로 분석해야 할 필요가 있을 때 유용합니다)

분석기 결과 확인

분석 과정에 생성된 토큰을 직접 확인하는 방법을 알아봅시다

예제) AnalyzerDeom 클래스 : 두 개의 문자열을 루씬의 기본 분석기로 분석합니다

지정한 분석기로 2개의 문자열을 각각 분석하며, 분석이 끝나고 추출된 토큰을 괄호로 묶어 알아 보기 쉽게 보여줍니다

public class AnalyzerDemo {

  private static final Analyzer[] analyzers = new Analyzer[] { 
    new WhitespaceAnalyzer(),
    new SimpleAnalyzer(),
    new StopAnalyzer(Version.LUCENE_30),
    new StandardAnalyzer(Version.LUCENE_30)
  };

  public static void main(String[] args) throws IOException {
    String[] strings = examples;
    if (args.length > 0) {    // 커맨드라인에 지정한 문자열을 분석
      strings = args;
    }

    for (String text : strings) {
      analyze(text);
    }
  }

  private static void analyze(String text) throws IOException {
    System.out.println("Analyzing \"" + text + "\"");
    for (Analyzer analyzer : analyzers) {
      String name = analyzer.getClass().getSimpleName();
      System.out.println("  " + name + ":");
      System.out.print("    ");
      AnalyzerUtils.displayTokens(analyzer, text); // B
      System.out.println("\n");
    }
  }
}

핵심적인 일은 AnalyzerUtils에서 진행합니다

즉, 원문 텍스트를 분석기에 입력하고, 결과로 생성된 토큰을 화면에 표시합니다

(분석기에 전달할 뿐 실제 색인을 하는 것은 아닙니다)

public class AnalyzerUtils {
  public static void displayTokens(Analyzer analyzer,
                                   String text) throws IOException {
    displayTokens(analyzer.tokenStream("contents", new StringReader(text))); 
		// -> 분석 프로세스 호출
  }

  public static void displayTokens(TokenStream stream)
    throws IOException {

    TermAttribute term = stream.addAttribute(TermAttribute.class);
    while(stream.incrementToken()) {
      System.out.print("[" + term.term() + "] ");    
			// -> 대괄호로 묶은 토큰 텍스트 출력
    }
	...
}

예제처럼 토큰을 하나씩 화면에 출력하는 작업이 아니라면 tokenStream 메소드를 직접 호출할 일은 거의 없습니다

 

위 코드를 동작한 후 , "No Fluff, Just Stuff"를 입력하면

WhitespaceAnalyzer: [No] [Fluff,] [Just] [Stuff]

SimpleAnalyzer: [no] [fluff] [just] [stuff]

WhitespaceAnalyzer: [No] [Fluff,] [Just] [Stuff]

Stop : [fluff] [just] [stuff]

StandardAnalyzer : [fluff] [just] [stuff]

와 같은 결과를 얻을 수 있습니다

토큰의 내부 구조

TokenFilter에서 흘러가는 토큰의 속성을 읽고 변경할 수 있습니다

public static void displayTokensWithFullDetails(Analyzer analyzer,
                                                  String text) throws IOException {

    TokenStream stream = analyzer.tokenStream("contents",                        // #A
                                              new StringReader(text));

    TermAttribute term = stream.addAttribute(TermAttribute.class);        // #B
    PositionIncrementAttribute posIncr =                                  // #B 
    	stream.addAttribute(PositionIncrementAttribute.class);              // #B
    OffsetAttribute offset = stream.addAttribute(OffsetAttribute.class);  // #B
    TypeAttribute type = stream.addAttribute(TypeAttribute.class);        // #B

    int position = 0;
    while(stream.incrementToken()) {                                  // #C

      int increment = posIncr.getPositionIncrement();                 // #D
      if (increment > 0) {                                            // #D
        position = position + increment;                              // #D
        System.out.println();                                         // #D
        System.out.print(position + ": ");                            // #D
      }

      System.out.print("[" +                                 // #E
                       term.term() + ":" +                   // #E
                       offset.startOffset() + "->" +         // #E
                       offset.endOffset() + ":" +            // #E
                       type.type() + "] ");                  // #E
    }
    System.out.println();
  }

#A : 텍스트 분석

#B : 출력하고자 하는 속성 준비

#C : 모든 토큰을 하나씩 반복

#D : 텀 단위의 위치를 계산해서 출력

#E : 토큰의 상세 정보 출력

 

SimpleAnalyzer로 분석한 결과를 출력해보자

public static void main(String[] args) throws IOException {
    AnalyzerUtils.displayTokensWithFullDetails(new SimpleAnalyzer(),
        "The quick brown fox....");
  }

position : [term:startOffset→endOffset:type]

1 : [the:0→3:word]

2 : [quick:4→9:word]

3 : [brown:10→15:word]

4 : [fox:16→19:word]

 

각 토큰은 직전 토큰의 바로 다음에 위치합니다 (맨 앞의 1,2,3,4가 텀의 위치)

이처럼 토큰의 모든 속성은 토큰 내부에서 Attribute 클래스로 표현합니다

 

속성

TokenStream에서 특정 토큰의 모든 속성을 담고 있는 객체를 생성하는 일은 없습니다

대신 토큰의 속성(텀, 텍스트에서의 위치, 위치 증가 값 등)마다 재사용하는 속성 인터페이스를 활용합니다

TokenStream은 AttributeSource라는 클래스를 상속받습니다

 

재사용할 수 있는 속성 API를 사용하려면 먼저 addAttribute 메소드를 호출해야 합니다

그러면 속성 클래스 인스턴스를 받을 수 있습니다

그리고 TokenStream.incrementToken 메소드를 호출해 토큰을 하나씩 반복합니다

처리할 토큰이 있으면 true, 없으면 false를 리턴합니다

이때 속성 클래스 인스턴스를 사용해 각 토큰의 속성 값을 불러올 수 있습니다

 

예제) 위치 증가 값만 알아내고 싶다면?

TokenStream stream = analyzer.tokenStream("contents",
                                            new StringReader(text));
TermAttribute term = stream.addAttribute(TermAttribute.class);
PositionIncrementAttribute posIncr = stream.addAttribute(PositionIncrementAttribute.class);

while (stream.incrementToken()) {
	System.out.println("posIncr=" + posIncr.getPositionIncrement());
}

루씬 내장 속성은 값을 읽거나 쓰는 양방향으로 모두 사용이 가능합니다

getPositionIncrement(), setPositionIncrement()

 

토큰에 대한 정보를 모두 저장해뒀다가 나중에 불러와 사용해야 하는 경우는?

captureState 메소드를 호출하면 현재 상태를 모두 담고 있는 State 객체를 받습니다

이 인스턴스를 통해 상태를 복원할 수 있습니다

하지만 이런 상태를 저장해두고 복원하는 작업은 성능에 좋지 않은 영향을 미치므로 최소화하는 편이 좋습니다

 

토큰 종류 활용

토큰의 종류 속성에 해당 토큰에 어떤 종류의 문자열이 담겨 있는지 기록할 수 있습니다

StandardAnalyzer에서 사용하는 StandardTokenizer 클래스는 미리 지정한 문법에 기반을 두고 입력받은 텍스트를 토큰으로 분리합니다

예를 들어 "I'll email you at xyz@example.com" 문자열을 분석하면 아래와 같습니다

1 : [i'll:0->4:<APOSTROPHE>]

2 : [email:5->10:<ALPHANUM>]

3 : [you:11->14:<ALPHANUM>]

5 : [xyz@example.com:18->33:<EMAIL>]

(불용어 at이 제거되어서 3다음 5입니다)

 

i'll : APOSTROPE(따옴표) - 따옴표가 있는 하나의 단어

at : 불용어 제거

등등... StandardAnalyzer의 다양한 기능은 나중에 다시 살펴보겠습니다

TokenFilter 순서의 중요성

분석기 체인을 연결하는 순서가 매우 중요한 경우가 있습니다

각 단계는 앞 단계의 TokenFilter 결과물에 전적으로 의존하기 때문입니다

예제) "The quick brown"

StopFilter와 LowerCaseFilter의 순서를 바꿔 분석기를 하나 만들었다고 합시다

public class StopAnalyzerFlawed extends Analyzer {
  private Set stopWords;

  public StopAnalyzerFlawed() {
    stopWords = StopAnalyzer.ENGLISH_STOP_WORDS_SET;
  }

  public StopAnalyzerFlawed(String[] stopWords) {
    this.stopWords = StopFilter.makeStopSet(stopWords);
  }

  /**
   * Ordering mistake here
   */
  public TokenStream tokenStream(String fieldName, Reader reader) {
    return new LowerCaseFilter(
           new StopFilter(true, new LetterTokenizer(reader),
                          stopWords));
  }
}

StopFilter는 모든 토큰이 소문자로 변환된 상태라고 가정하기 때문에 대소문자 구분 없이 단어를 비교합니다.

따라서 소문자로 먼저 바뀌지 않고 StopFilter 후에 적용되므로 The는 지워지지 않고 the로 남습니다

분석기 체인의 순서에 따른 성능 향상

예를 들어 불용어 제거와 유의어 추가를 하는 분석기가 있습니다

먼저 불용어를 제거한 후, 유의어를 추가하면 확인해야 할 토큰 개수가 줄어들어 효율이 높아집니다

루씬 내장 분석기

루씬에는 여러 종류의 분석기가 내장되어 있습니다

루씬에 내장된 다양한 종류의 Tokenizer와 TokenFilter를 조합해 구현합니다

내장된 분석기는 대부분 서양 언어를 대상으로 분석합니다

StopAnalyzer

기본적인 단어를 분리하고 소문자로 변경한 후 불용어를 제거합니다

불용어 목록이 ENGLISH_STOP_WORDS_SET 변수에 들어있습니다

별도로 설정하지 않으면 기본 목록을 그대로 사용합니다

(따로 생성할 때 불용어 목록을 설정할 수도 있습니다)

StandardAnalyzer

가장 손쉽게 일반적으로 사용할 수 있는 범용 분석기입니다

JFlex 기반 문법을 사용합니다 (고성능 고급 구문 분석기)

글자와 숫자로 이뤄진 단어, 약어, 회사 이름, 이메일, 서버 주소, 숫자, 따옴표가 포함된 단어, 일련번호, IP 주소, 중국어와 일본어 글자 등을 인식할 수 있습니다

또한 StopAnalyzer와 같은 방법으로 불용어도 제거합니다

 

 

 

 

 

이 글은 “Lucene In Action” 책 내용을 요약한 글입니다.

만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

 

 

 

댓글