본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 7. 분석기(2) - 유사어, 기본형 분석기

by 잭피 2020. 12. 21.

유사 발음 검색

발음 기본형을 찾아내는 분석기를 구현해봅시다

메타폰, 사운덱스와 같은 알고리즘을 사용할 수 있습니다

 

메타폰 알고리즘을 사용한 예를 한번 볼까요?

public class MetaphoneAnalyzerTest extends TestCase {
  public void testKoolKat() throws Exception {
    RAMDirectory directory = new RAMDirectory();
    Analyzer analyzer = new MetaphoneReplacementAnalyzer();

    IndexWriter writer = new IndexWriter(directory, analyzer, true,
                                         IndexWriter.MaxFieldLength.UNLIMITED);

    Document doc = new Document();
    doc.add(new Field("contents", //#A
                      "cool cat",
                      Field.Store.YES,
                      Field.Index.ANALYZED));
    writer.addDocument(doc);
    writer.close();

    IndexSearcher searcher = new IndexSearcher(directory);

    Query query = new QueryParser(Version.LUCENE_30,  //#B
                                  "contents", analyzer)    //#B
                              .parse("kool kat");          //#B

    TopDocs hits = searcher.search(query, 1);
    assertEquals(1, hits.totalHits);   //#C
    int docID = hits.scoreDocs[0].doc;
    doc = searcher.doc(docID);
    assertEquals("cool cat", doc.get("contents"));   //#D

    searcher.close();
  }

#A : 예제 문서 색인

#B : 질의어 파싱

#C : 검색 결과 확인

#D : 원문 텍스트 확인

 

사용자가 "kool kat"이라는 검색어를 입력했는데, "cool cat"을 찾아냅니다

이렇게 유사 발음으로 검색하는 핵심 기능은 MetaphoneReplacementAnalyzer 분석기가 맡고 있습니다

 

메타폰 알고리즘은 토큰으로 글자만 인식하기 때문에 LetterTokenizer를 사용해 글자 토큰을 생성합니다

실제 모든 작업이 이뤄지는 MetaphoneReplacementFilter 클래스를 살펴볼까요?

public class MetaphoneReplacementFilter extends TokenFilter {
  public static final String METAPHONE = "metaphone";

  private Metaphone metaphoner = new Metaphone();
  private TermAttribute termAttr;
  private TypeAttribute typeAttr;

  public MetaphoneReplacementFilter(TokenStream input) {
    super(input);
    termAttr = addAttribute(TermAttribute.class);
    typeAttr = addAttribute(TypeAttribute.class);
  }

  public boolean incrementToken() throws IOException {
    if (!input.incrementToken())                    //#A  
      return false;                                 //#A  

    String encoded;
    encoded = metaphoner.encode(termAttr.term());   //#B
    termAttr.setTermBuffer(encoded);                //#C
    typeAttr.setType(METAPHONE);                    //#D
    return true;
  }
}

#A : 다음 토큰으로 이동

#B : 메타폰 알고리즘으로 텀 변환

#C : 변환된 텀을 토큰에 재설정

#D : 토큰 종류 지정

 

MetaphoneReplacementFilter 필터 결과 모든 토큰을 메타폰 형태로 변환합니다

토큰을 리턴하기 직전 토큰의 종류를 "metaphone"으로 지정하고 있습니다

필요한 경우 분석기 체인에서 토큰의 종류를 확인해 메타폰 토큰이라면 별도의 방법으로 따로 처리할 수 있습니다

 

MetaphoneReplacementFilter에서 사용하는 metaphone 같은 토큰 종류는 분석 과정에서는 계속 유지하지만, 색인에는 보관되지 않습니다
특별히 명시하지 않는 한 모든 토큰의 종류는 기본적으로 word입니다

 

AnalyzerUtils 클래스를 활용해 분석기가 어떻게 동작하는지 확인해봅시다

(발음이 비슷한 두 개의 문장을 분석해 어떤 토큰이 생성됐는지 확인)

public static void main(String[] args) throws IOException {
    MetaphoneReplacementAnalyzer analyzer =
                                 new MetaphoneReplacementAnalyzer();
    AnalyzerUtils.displayTokens(analyzer,
                   "The quick brown fox jumped over the lazy dog");

    System.out.println("");
    AnalyzerUtils.displayTokens(analyzer,
                   "Tha quik brown phox jumpd ovvar tha lazi dag");
  }

결과는 아래와 같습니다

[0] [KK] [BRN] [FKS] [JMPT] [OFR] [0] [LS] [TKS]

[0] [KK] [BRN] [FKS] [JMPT] [OFR] [0] [LS] [TKS]

비교 원문은 서로 다르지만, 메타폰 분석 결과는 일치합니다

 

하지만 실제 유사 발음 검색 기능이 필요한 경우가 많지 않고, 비슷한 발음의 결과 문서가 너무 많아 큰 의미를 두기 어렵습니다

보통 검색 결과가 하나도 찾을 수 없을 때 발음이 유사한 단어를 조회해 추천해줄 때 사용합니다

 

유사어 검색

자동차를 검색하면 차량이라는 단어를 담고 있는 문서가 검색되거나, 인도 요리를 검색하면 인도 음식을 담고 있는 문서가 검색되면 사용자 경험을 크게 향상 시킬 수 있습니다

즉, 유사어를 적절히 처리해둬야 사용자가 원하는 문서를 찾을 수 있습니다

 

색인 과정에 유사어를 추가하면 검색할 때 원문에는 없지만 유사어로 등록된 단어에 해당하는 문서도 조회할 수 있습니다

유사어 분석기 테스트를 한번 볼까요?

public void testJumps() throws Exception {
    TokenStream stream =
      synonymAnalyzer.tokenStream("contents",                   // #A
                                  new StringReader("jumps"));   // #A
    TermAttribute term = stream.addAttribute(TermAttribute.class);
    PositionIncrementAttribute posIncr = stream.addAttribute(PositionIncrementAttribute.class);

    int i = 0;
    String[] expected = new String[]{"jumps",              // #B
                                     "hops",               // #B
                                     "leaps"};             // #B
    while(stream.incrementToken()) {
      assertEquals(expected[i], term.term());

      int expectedPos;      // #C
      if (i == 0) {         // #C
        expectedPos = 1;    // #C
      } else {              // #C
        expectedPos = 0;    // #C
      }                     // #C
      assertEquals(expectedPos,                      // #C
                   posIncr.getPositionIncrement());  // #C
      i++;
    }
    assertEquals(3, i);
  }

#A : 유사어 분석기로 토큰 생성

#B : 유사어가 제대로 들어있는지 확인

#C : 유사어의 위치 확인

expected 배열에 들어간 유사어들 위치 증가 값을 모두 0으로 설정한다

 

유사어 분석기 작성

SynonymAnalyzer는 먼저 유사어에 해당하는 토큰인지 확인하고,

해당하는 유사어가 있다면 토큰과 같은 위치에 유사어 토큰을 추가합니다

public class SynonymAnalyzer extends Analyzer {
  private SynonymEngine engine;

  public SynonymAnalyzer(SynonymEngine engine) {
    this.engine = engine;
  }

  public TokenStream tokenStream(String fieldName, Reader reader) {
    TokenStream result = new SynonymFilter(
                          new StopFilter(true,
                            new LowerCaseFilter(
                              new StandardFilter(
                                new StandardTokenizer(
                                 Version.LUCENE_30, reader))),
                            StopAnalyzer.ENGLISH_STOP_WORDS_SET),
                          engine
                         );
    return result;
  }
}

분석기 자체의 코드는 그다지 복잡하지 않습니다

여러 종류의 Tokenizer 클래스를 분석기 체인으로 연결할 뿐입니다

SynonymAnalyzer는 StandardAnalyzer와 동일한 기능에 유사어 관련 필터(SynonymFilter)만 추가됩니다

 

분석기에서 텀을 추가하려면 추가된 텀을 보관할 공간이 필요합니다

SynonymFilter에서는 버퍼로 Stack 클래스를 사용합니다

SynonymFilter : 유사어를 찾아 버퍼에 담아두고 하나씩 알려줍니다

public class SynonymFilter extends TokenFilter {
  public static final String TOKEN_TYPE_SYNONYM = "SYNONYM";

  private Stack<String> synonymStack;
  private SynonymEngine engine;
  private AttributeSource.State current;

  private final TermAttribute termAtt;
  private final PositionIncrementAttribute posIncrAtt;

  public SynonymFilter(TokenStream in, SynonymEngine engine) {
    super(in);
    synonymStack = new Stack<String>();                     //#1 
    this.engine = engine;

    this.termAtt = addAttribute(TermAttribute.class);
    this.posIncrAtt = addAttribute(PositionIncrementAttribute.class);
  }

  public boolean incrementToken() throws IOException {
    if (synonymStack.size() > 0) {                          //#2
      String syn = synonymStack.pop();                      //#2
      restoreState(current);                                //#2
      termAtt.setTermBuffer(syn);
      posIncrAtt.setPositionIncrement(0);                   //#3
      return true;
    }

    if (!input.incrementToken())                            //#4  
      return false;

    if (addAliasesToStack()) {                              //#5 
      current = captureState();                             //#6
    }

    return true;                                            //#7
  }

  private boolean addAliasesToStack() throws IOException {
    String[] synonyms = engine.getSynonyms(termAtt.term()); //#8
    if (synonyms == null) {
      return false;
    }
    for (String synonym : synonyms) {                       //#9
      synonymStack.push(synonym);
    }
    return true;
  }
}

#1 : 유사어를 담아 둘 버퍼입니다 (Stack)

#2 : 버퍼에 담겨있는 유사어를 뽑아냅니다

#3 : 위치 증가 값을 0으로 지정합니다

#4 : 다음 토큰을 읽습니다

#5 : 유사어를 찾아내 버퍼에 담습니다

#6 : 현재 토큰을 보관합니다

#7 : 현재 토큰을 리턴합니다

#8 : 유사어 추출

#9 : 유사어를 버퍼에 보관

 

SynonymEngine을 지정해 유사어를 손쉽게 지정할 수 있게 하였습니다

public interface SynonymEngine {
  String[] getSynonyms(String s) throws IOException;
}

예제에서는 간단히 다루지만, 실제 검색 애플리케이션에서 사용할 때는 전문적인 라이브러리를 사용하는게 좋습니다

ex) 워드넷(WordNet)에 기반을 둔 강력한 SynonymEngine

public class TestSynonymEngine implements SynonymEngine {
  private static HashMap<String, String[]> map = new HashMap<String, String[]>();

  static {
    map.put("quick", new String[] {"fast", "speedy"});
    map.put("jumps", new String[] {"leaps", "hops"});
    map.put("over", new String[] {"above"});
    map.put("lazy", new String[] {"apathetic", "sluggish"});
    map.put("dog", new String[] {"canine", "pooch"});
  }

  public String[] getSynonyms(String s) {
    return map.get(s);
  }
}

TestSynonymEngine에서 생성한 유사어는 단방향 유사어입니다

위의 예제를 보면 quick은 fast, speedy라는 유사어를 갖고 있지만, 반대는 아닙니다

실무에서 사용할 땐 양방향 유사어 분석기로 구현해야합니다

 

유사어도 일반 텀과 동일하게 취급하므로 추가된 유사어도 TermQuery나 PhraseQuery에서 동작합니다

public class SynonymAnalyzerTest extends TestCase {
	... // 생략 
  public void testSearchByAPI() throws Exception {

    TermQuery tq = new TermQuery(new Term("content", "hops"));  //#1
    assertEquals(1, TestUtil.hitCount(searcher, tq));

    PhraseQuery pq = new PhraseQuery();    //#2
    pq.add(new Term("content", "fox"));    //#2
    pq.add(new Term("content", "hops"));   //#2
    assertEquals(1, TestUtil.hitCount(searcher, pq));
  }

#1 : hops 단어로 검색해도 결과를 찾을 수 있습니다

#2 : fox hops 구문으로 검색해도 결과를 찾을 수 있습니다

 

하나의 예를 더 볼까요?

QueryParser 인스턴스를 2개 생성해봅니다

1. SynonymAnalyzer

2. StandardAnalyzer

2개의 분석기를 각각 지정해 QueryParser 인스턴스 각각 1개씩 생성합니다

public void testWithQueryParser() throws Exception {
    Query query = new QueryParser(Version.LUCENE_30,                   // 1
                                  "content",                                // 1
                                  synonymAnalyzer).parse("\"fox jumps\"");  // 1
    assertEquals(1, TestUtil.hitCount(searcher, query));                   // 1
    System.out.println("With SynonymAnalyzer, \"fox jumps\" parses to " +
                                         query.toString("content"));

    query = new QueryParser(Version.LUCENE_30,                         // 2
                            "content",                                      // 2
                            new StandardAnalyzer(Version.LUCENE_30)).parse("\"fox jumps\""); // B
    assertEquals(1, TestUtil.hitCount(searcher, query));                   // 2
    System.out.println("With StandardAnalyzer, \"fox jumps\" parses to " +
                                         query.toString("content"));
  }

한번 결과를 볼까요?

1. SynonymAnalyzer : "fox, jumps" parses to "fox (jumps hops leaps)"

2. StandardAnalyzer : "fox, jumps" parses to "fox jumps"

 

SynonymAnalyzer는 검색어에 입력했던 단어 중 유사어가 있는 단어를 확장해 질의를 생성했습니다

 

토큰 위치 증가 값 확인

분석된 각 토큰의 위치 증가 값을 표시봅시다

public class SynonymAnalyzerViewer {

  public static void main(String[] args) throws IOException {
    SynonymEngine engine = new TestSynonymEngine();

    AnalyzerUtils.displayTokensWithPositions(
      new SynonymAnalyzer(engine),
      "The quick brown fox jumps over the lazy dog");
  }
}
public static void displayTokensWithPositions
    (Analyzer analyzer, String text) throws IOException {

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

    int position = 0;
    while(stream.incrementToken()) {
      int increment = posIncr.getPositionIncrement();
      if (increment > 0) {
        position = position + increment;
        System.out.println();
        System.out.print(position + ": ");
      }

      System.out.print("[" + term.term() + "] ");
    }
    System.out.println();
  }

결과

2: [quick] [speedy] [fast]

3: [brown]

4: [fox]

5: [jumps] [hops] [leaps]

6: [over] [above]

7: [lazy] [sluggish] [apathetic]

8: [lazy] [sluggish] [apathetic]

9: [dog] [pooch] [canine]

기본형 분석기

PositionalPorterStopAnalyzer

1. 불용어를 제거합니다

2. 제거된 토큰의 위치를 그대로 보존합니다

3. 기본형을 찾는 형태소 분석기 기능을 사용합니다

 

PorterStemFilter는 루씬에 내장된 분석기에서는 사용하지 않는 토큰 필터입니다

마틴 포터 박사가 고안한 기본형 찾기 알고리즘을 사용합니다

다양한 형태로 변환된 단어를 기본형으로 다시 되돌려줍니다

예를 들어 breathe, breathes, breathing, breathed 등의 단어들을 기본형인 breath로 변환시켜줍니다

이와 비슷한 알고리즘으로는 Snowball, KStem 알고리즘이 있습니다

 

StopFilter와 빈 공간

불용어를 제거하면 빈 공간이 생깁니다

예를 들어 "one is not enough"라는 문장을 StopAnalyzer로 분석하면

is와 not은 제거하고 one enough 2개의 토큰만 생성합니다

 

만약 위치 증가 값을 모두 1로 지정해버리면 빈 공간을 놓치게 됩니다

이런 빈 공간을 무시한다면 "one enough"라는 구문 질의가 "one is not enouh"라는 문서를 찾아낼 것 입니다

 

불용어는 문서를 구분하는 특별한 의미를 갖고 있지 않기 때문에 제거합니다

어떤 언어에서나 사용하는 실제 의미를 갖고 있는 단어를 도와주는 단어일 뿐입니다

문제는 불용어를 제거하면 일부 정보를 잃어버린다는 점입니다

 

검색 종류에 따라 이 정보가 문제가 되기도 하고, 아니면 문제가 없을 수도 있습니다

 

불용어에 대해 싱글이라는 대안도 있습니다

싱글은 서로 인접한 여러 개의 토큰을 하나로 묶어 생성한 토큰을 말합니다

루씬에는 contrib 모듈로 SingleFilter 클래스가 있습니다

(분석 과정에서 자동으로 싱글 토큰을 생성합니다)

 

싱글 토큰에 대해서는 나중에 다시 한번 설명하겠습니다

불용어를 다음 단어와 붙여 the-quick과 같은 형태로 만들면 바로 싱글 토큰이 됩니다

검색할 때도 이런 싱글 토큰을 생성해 검색합니다

따라서 검색할 때 불용어가 포함돼 있어도 토큰에는 여전히 포함돼 있기 때문에 정확하게 검색할 수 있습니다

 

이런 싱글 토큰을 사용하면 the 단어를 포함하는 문서보다 the-quick 단어를 포함하는 문장의 수가 훨씬 적기 때문에 검색 속도를 향상시킬 수 있습니다

기본형 찾기와 불용어 처리 방법 변경

LowerCaseTokenizer에서 소문자로 변경된 토큰을 받아오고, 불용어를 제거한 이후 빈 공간은 그대로 유지하는 불용어 제거 필터를 사용한 분석기를 작성해봅시다

LowerCaseTokenizer에서 시작하여, 불용어 제거 필터를 거쳐 최종적으로 포터 기본형 찾기 필터를 거칩니다

(PositionalPorterStopAnaylzer : 불용어를 제거하고 기본형을 찾아내는 분석기)

public class PositionalPorterStopAnalyzer extends Analyzer {
  private Set stopWords;

  public PositionalPorterStopAnalyzer() {
    this(StopAnalyzer.ENGLISH_STOP_WORDS_SET);
  }

  public PositionalPorterStopAnalyzer(Set stopWords) {
    this.stopWords = stopWords;
  }

  public TokenStream tokenStream(String fieldName, Reader reader) {
    StopFilter stopFilter = new StopFilter(true,
                                           new LowerCaseTokenizer(reader),
                                           stopWords);
    stopFilter.setEnablePositionIncrements(true);
    return new PorterStemFilter(stopFilter);
  }
}

필드 유형별 처리

루씬의 문서는 여러 개의 필드로 구성돼 있고, 필드별로 매우 다양한 특성을 가집니다

따라서 분석 과정에서 고려해야 할 사항이 많습니다

동일한 이름의 필드

루씬의 문서는 동일한 이름으로 지정된 여러 개의 Field 인스턴스를 담을 수 있습니다

그리고 내부적으로 동일한 이름의 필드 내용을 연결해 처리합니다

 

분석기에서 각 필드 값을 넘어갈 때마다 약간의 상황을 파악할 수 있습니다

ex) 구문 질의나 스팬 질의에서 토큰의 위치 증가 값

 

만약 "its time to pay income tax" 라는 필드와 "return library books on time"이라는 필드가 있을 때 사용자가 "tax return"이라는 구문으로 검색하면 결과에 포함돼 버립니다

 

이런 문제가 발생하지 않도록 하려면 어떻게 해야할까요?

먼저 Analyzer 클래스를 상속받아 별도의 분석기 클래스를 구현합니다

tokenStream 메소드와 함께 getPositionIncrementGap 메소드를 오버라이드합니다

기본 값으로 0을 리턴하며, 0 값 → (필드의 값 사이에 빈 공간 없이 붙여서 처리해야 한다는 뜻)

getPositionIncrementGap 메소드에서 적당히 큰 값 (예를 들어 100)을 리턴하게 변경하면 필드 경계를 넘어갈 때 위치 증가 값이 크게 달라지기 때문에 일반적인 구문 질의 등으로 해당 문서를 찾을 수 없습니다

 

위치 증가 값뿐만 아니라 각 토큰의 시작과 끝 지점의 값도 적절하게 지정해야 합니다

같은 이름을 갖고 있는 여러 개의 필드의 값에서 요약문을 뽑아내고 하이라이팅할 때 시작과 끝 지점의 값이 잘못 지정돼 있다면 잘못된 위치를 하이라이팅할 수 있습니다

(getOffsetGap 메소드를 오버라이드)

 

필드별 분석기 지정

문서를 색인할 때 분석기는 IndexWrtier에 문서 단위만으로 지정할 수 있습니다

QueryParser에서도 지정한 분석기를 사용해 모든 필드의 검색어를 분석합니다

필드마다 서로 다른 방법으로 분석해야 할 경우가 많습니다

 

tokenStream 메소드에 현재 처리하는 필드의 이름을 넘겨받기 때문에 필드별로 서로 다른 방법으로 분석할 수 있습니다

분석기를 직접 구현하거나,

필드별로 분석기를 지정하는 기능을 지원하는 PrefieldAnalyzerWrapper 클래스를 사용할 수 있습니다

PreFieldAnalyzerWrapper analyzer = new PerFieldAnalyzerWrapper(new SimpleAnalyzer());
analyzer.addAnalyzer("body", new StandardAnalyzer(Version.LUCENE_30));

분석하지 않은 검색

분석하지 않고 색인하려면 해당 필드에 Field.Index.NOT_ANALYZED 또는 Field.Index.NOT_ANALYZED_NO_NORMS 등 값을 설정하면 됩니다

 

검색할 때 TermQuery로 검색하면 손쉽게 찾을 수 있지만,

QueryParser를 사용해 검색어를 질의로 변환하면서 분석하지 않고 색인한 필드까지 함께 처리하려면 곤란해집니다

QueryParser는 필드마다 분석하고 색인했는지의 여부를 모르기 때문입니다

 

예제를 하나 볼까요?

public class KeywordAnalyzerTest extends TestCase {

  private IndexSearcher searcher;

  public void setUp() throws Exception {
    Directory directory = new RAMDirectory();

    IndexWriter writer = new IndexWriter(directory,
                                         new SimpleAnalyzer(), 
                                         IndexWriter.MaxFieldLength.UNLIMITED);

    Document doc = new Document();
    doc.add(new Field("partnum",
                      "Q36",
                      Field.Store.NO,
                      Field.Index.NOT_ANALYZED_NO_NORMS));   //A
    doc.add(new Field("description",
                      "Illidium Space Modulator",
                      Field.Store.YES,
                      Field.Index.ANALYZED));
    writer.addDocument(doc);

    writer.close();

    searcher = new IndexSearcher(directory);
  }

  public void testTermQuery() throws Exception {
    Query query = new TermQuery(new Term("partnum", "Q36"));  //B
    assertEquals(1, TestUtil.hitCount(searcher, query)); //C
  }

  public void testBasicQueryParser() throws Exception {
    Query query = new QueryParser(Version.LUCENE_30,                //1
                                  "description",                //1
                                  new SimpleAnalyzer())            //1
                      .parse("partnum:Q36 AND SPACE");                //1
    assertEquals("note Q36 -> q",
                 "+partnum:q +space", query.toString("description"));    //2
    assertEquals("doc not found :(", 0, TestUtil.hitCount(searcher, query));
  }
}

A : 분석하지 않은 필드

B : 분석하지 않고 텀 질의 사용

C : 검색 결과 확인

1 : QueryParser는 질의 표현식의 각 검색어와 구문을 분석합니다

색인할 때는 분석하지 않고 넣었으므로 Q36이 들어가있지만, 조회할 땐 SimpleAnalyzer 분석으로 q로 변환됩니다

2 : 따라서 Q36 검색어를 찾아볼 수 없습니다

 

어떻게 해결해야 할까요?

QueryParser의 하위 클래스를 만들어 getFieldQuery 메소드를 새로 구현해 필드 이름에 따라 다른 방법으로 처리하면 됩니다

또는 필드별로 분석기를 지정한 PerFieldAnalyzerWrapper 객체를 사용합니다 (가장 간단한 방법)

public void testPerFieldAnalyzer() throws Exception {
    PerFieldAnalyzerWrapper analyzer = new PerFieldAnalyzerWrapper(
                                              new SimpleAnalyzer());
    analyzer.addAnalyzer("partnum", new KeywordAnalyzer());

    Query query = new QueryParser(Version.LUCENE_30,
                                  "description", analyzer).parse(
                "partnum:Q36 AND SPACE");

    assertEquals("Q36 kept as-is",
              "+partnum:Q36 +space", query.toString("description"));  
    assertEquals("doc found!", 1, TestUtil.hitCount(searcher, query));
  }

PerFieldAnalyzerWrapper 클래스를 사용해 partnum 필드에만 KeywordAnalyzer를 적용합니다

나머지 모든 필드는 SimpleAnalyzer를 적용합니다

 

즉, 색인할 때 사용했던 분석 방법과 동일한 방법으로 맞춥니다

색인과 검색 양쪽에서 모두 사같은 분석기를 사용하는 방법이 코드의 가독성에도 도움을 주고,

PerFieldAnalyzerWrapper 와 KeywordAnalyzer를 통해 구조를 간단하게 유지할 수 있습니다

 

언어별 분석

여러 언어로 작성된 원문을 분석할 때는 여러 가지 주의할 점이 있습니다

 

1. 원문의 문자 집합 인코딩이 올바르게 지정돼 정상적으로 읽어 들일 수 있는지

2. 언어별 서로 다른 불용어 준비

3. 언어별 기본형 찾기 알고리즘을 준비

4. 언어별 글자에 붙은 액센트 제거

5. 모르는 언어의 파악

 

루씬은 아주 기본적인 틀만 갖추고 있어서 개발자가 직접 해결해야 합니다

(루씬의 contrib 모듈로 포함된 여러 종류의 Tokenizer나 TokenStream 등을 사용하면 도움을 받을 수 있습니다)

 

유니코드와 인코딩

루씬은 내부적으로 모든 글자를 UTF-8 인코딩에 맞춰 보관합니다

자바 언어는 String 클래스에서 자동으로 모든 문자열을 유니코드(UTF-16)로 다루기 때문에 인코딩과 관련된 복잡한 문제를 상당 부분 덜어줍니다

비영어권 언어 분석

비영어권 언어로 작성된 문서를 색인한다 해도 색인 과정에서 발생하는 세부적인 사항은 모두 동일하게 적용할 수 있습니다

최종적으로 원문에서 텀을 뽑아내기만 하면 됩니다

공백, 기호를 사용해 단어를 구분하는 작업은 비슷하지만, 불용어나 기본형 찾기 알고리즘은 반드시 언어에 맞게 별도로 지정해야 합니다

contrib/analyzers 안에도 다수의 언어별 분석기가 포함되어 있습니다

글자 정규화

Reader에서 불러오는 글자를 정규화해서 Tokneizer로 넘겨줍니다

이 단계가 중요한 이유는 필터를 거치면서 단어에서 글자의 추가/제거 등의 변화가 있기 전

시작과 끝 지점을 정확히 기록할 수 있습니다

나중에 하이라이팅 등의 작업을 진행할 때 원문에서 정확한 위치를 찾아낼 수 있습니다

 

루씬은 토큰 단위의 필터처럼 잘 구성된 글자 필터링 클래스를 가지고 있습니다

CharFilter 클래스를 사용하면 어러 개의 CharStream 클래스를 체인으로 연결할 수 있습니다

이런 글자 필터링 관련 클래스를 잘 조합하면 글자 필터링 체인을 만들 수 있습니다

 

루씬에는 CharFilter를 구현하는 MappingCharFilter 하나가 포함돼 있습니다

입력하는 글자를 특정 글자로 변환해 출력해줍니다

이렇게 문자열의 일부를 다른 문자열로 변환하는 기능은 성능을 크게 떨어뜨릴 수 있다는 점을 주의해야 합니다

(MappingCharFilter 클래스는 메모리에 수많은 임시 객체를 생성하는 방법으로 구현했기 때문입니다)

 

현재 루씬에 내장된 기본 분석기는 글자 필터링 기능을 사용하지 않습니다

아시아 언어(CJK) 분석

한국어, 중국어 일본어 같은 아시아 언어는 줄여서 CJK라고 부릅니다

현재 루씬에 내장된 기본 분석기 중 StandardAnalyzer만이 CJK 언어를 대상으로 의미 있는 분석 작업을 할 수 있습니다

StandardAnalyzer는 일부 유니코드 블록에 해당하는 글자를 CJK 언어로 인식해 별도로 토큰을 분리합니다

루씬 contrib 모듈에 들어있는 분석기 중 CJK 언어를 다룰 수 있는 분석기로는 CJKAnalyzer, ChineseAnalyzer, SmartChineseAnalyzer 3가지가 있습니다

각각의 분석기들은 나중에 자세히 알아봅시다

Zaijian

색인에서 다수의 언어로 작성된 원문을 보관할 때 가장 어려운 점은 원문의 인코딩을 처리하는 부분입니다

중국어를 처리할 때는 contrib 모듈로 제공하는 SmartChineseAnalyzer를 사용하면 좋습니다

여러 종류의 언어로 작성된 원문을 하나의 색인에 추가하려면 문서마다 서로 다른 분석기를 사용하는 편이 좋습니다

 

 

 

 

 

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

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

 

 

 

댓글