본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 8. 고급 검색 기법(1) - 필드 캐시, 정렬

by 잭피 2021. 1. 4.

고급 검색 기법에서는 아래와 같은 방법을 알아보겠습니다

1. 텀의 위치 정보를 활용한 스팬 질의 사용 방법 
2. 유사어를 검색할 수 있는 MultiPhraseQuery 클래스 
3. FieldSelector 클래스를 사용해 검색 결과 문서에서 원하는 필드만 불러오는 방법 
4. 어러 개의 루씬 색인을 대상으로 검색하는 방법 
5. 일정 시간이 지나면 진행 중인 검색 작업을 중단하는 방법 
6. QueryParser에 기반을 둔 별도의 클래스를 사용해 여러 개의 필드를 한 번에 검색하는 방법


필드 캐시

필드 캐시는 특정 필드값을 순차적으로 조회할 목적으로 만들어졌습니다

사용자에게 노출하게 준비한 기능은 아니며, 검색 애플리케이션 입장에서 고급 검색 기능을 구현할 때 사용할 수 있는 API라고 봐야합니다

어디에 사용?

특정 필드 값을 기준으로 검색 결과를 정렬하는 등의 기능을 사용하면 루씬 내부적으로 필드 캐시를 사용합니다

정렬뿐만 아니라 루씬에 내장된 일부 필터와 함수 질의 등의 내부에서도 필드 캐시를 사용합니다

주의할 점

필드 캐시 API를 사용할 때 주의해야 할 점은 해당 필드에 단 하나의 텀만 들어 있어야 합니다

다시 말해 필드 캐시를 사용하려는 필드는 Index.NOT_ANALYZED 또는 Index.NOT_ANALYZED_NO_NORMS 등으로 설정해야 합니다

아니면 KeywordAnalyzer처럼 단 하나의 토큰만 생성한다는 보장이 있는 분석기를 사용해 분석해도 좋습니다

 

모든 문서의 필드 값 불러오기

필드 캐시를 사용하면 색인에 들어있는 모든 특정 필드의 값을 배열로 확보할 수 있습니다

예를 들어 모든 문서에 'weight'라는 필드가 있다면,

float[] weights = FieldCache.DEFAULT.getFloats(reader, "weight");

이렇게 필드 캐시 배열을 받아 올 수 있습니다

float[] weights = FieldCache.DEFAULT.getFloats(reader, "weight");

필드 캐시는 byte, short, int, float, double, String 등의 자료형, 그리고 문자열과 정렬 순서를 담고 있는 StringIndex 자료형을 지원합니다

필드 캐시를 처음으로 사용하려면 해당 색인의 전체 문서 개수 크기만큼 대형 배열을 생성하고 모든 문서의 해당 필드값을 모두 읽어 배열에 저장합니다

(상당한 시간이 걸릴 수 있음)

한번 생성한 배열은 캐시로 동작하므로 이후에 원하는 값을 즉시 찾아낼 수 있습니다

 

필드 캐시가 사용하는 메모리의 양도 주의해야합니다

필드 캐시는 색인의 크기와 자료형에 따라 예상외로 많은 메모리를 필요로 합니다
각 필드 캐시마다 (전체 문서 개수 크기 X 필드 자료형의 메모리 사용량) 만큼의 메모리가 필요합니다
해당하는 IndexReader를 닫고 필드 캐시를 참조하는 부분이 없어야 가비지 컬렉션을 통해 메모리가 반환됩니다

 

세그먼트별 IndexReader

루씬 2.9부터 검색 결과를 찾아내고 정렬하는 기능은 모두 세그먼트 단위로 동작합니다

즉, 필드 캐시를 생성할 때 IndexReader 인스턴스는 항상 단 하나의 세그먼트를 대상으로 한다는 뜻입니다

IndexReader를 다시 열 때 변경된 세그먼트의 필드 캐시만 새로 만들기 때문에 성능 향상에 큰 도움이 됩니다

 

필드 캐시를 받아오고자 할 때 2개 이상의 세그먼트를 담고 있는 상위 IndexReader를 넘겨줘서는 안된다 (결국 중복해서 필드 캐시를 만드는 셈이며, 메모리를 2배 사용하게 됩니다)

 

두 개 이상의 세그먼트를 담고 있는 상위 IndexReader를 필드 캐시 API에 넘겨주지 말자 만약 넘겨주면 IndexReader 안에 속한 모든 세그먼트를 모두 필드 캐시 API에 넘겨주는 셈이기 때문에 2배 또는 그 이상의 메모리를 소요할 수 있습니다

검색 결과 정렬

특정 필드를 대상으로 하는 정렬 기능은 해당하는 필드 캐시를 활용합니다

필드 값으로 정렬

search(Query, Filter, int, Sort)

기본적으로 Sort 인자를 넘겨받아 검색하는 경우 연관도 점수를 계산하지 않습니다

연관도 점수를 계산하지 않으면 검색 성능도 개선됩니다

필드 값 정렬이어도 연관도 점수가 필요하다면,

IndexSearcher 클래스의 setDefaultFieldSortScoring 메소드에서 doTrackScores 인자에 true 값을 지정하면 모든 결과 문서의 연관도 점수를 계산합니다

doMaxScore 인자에 true 값을 지정하면 모든 결과 문서의 연관도 점수 중 최댓값만 계산합니다

(문서별 점수를 계산하는 일보다 최대 점수를 계산하는 일이 좀 더 작업량이 많습니다)

 

코드를 통해 정렬 기능을 살펴볼까요?

public class SortingExample {
  private Directory directory;

  public SortingExample(Directory directory) {
    this.directory = directory;
  }

  public void displayResults(Query query, Sort sort)            // #1
      throws IOException {
    IndexSearcher searcher = new IndexSearcher(directory);

    searcher.setDefaultFieldSortScoring(true, false);            // #2

    TopDocs results = searcher.search(query, null,         // #3
                                      20, sort);           // #3

    System.out.println("\nResults for: " +                      // #4
        query.toString() + " sorted by " + sort);

    System.out.println(StringUtils.rightPad("Title", 30) +
      StringUtils.rightPad("pubmonth", 10) +
      StringUtils.center("id", 4) +
      StringUtils.center("score", 15));

    PrintStream out = new PrintStream(System.out, true, "UTF-8");    // #5

    DecimalFormat scoreFormatter = new DecimalFormat("0.######");
    for (ScoreDoc sd : results.scoreDocs) {
      int docID = sd.doc;
      float score = sd.score;
      Document doc = searcher.doc(docID);
      out.println(
          StringUtils.rightPad(                                                  // #6
              StringUtils.abbreviate(doc.get("title"), 29), 30) +                // #6
          StringUtils.rightPad(doc.get("pubmonth"), 10) +                        // #6
          StringUtils.center("" + docID, 4) +                                    // #6
          StringUtils.leftPad(                                                   // #6
             scoreFormatter.format(score), 12));                                 // #6
      out.println("   " + doc.get("category"));
    }

    searcher.close();
  }

#1 : 정렬하고자 하는 정보가 담긴 Sort 객체를 넘겨받습니다

#2 : IndexSearcher에서 결과 건별로 연관도 점수를 계산하도록 설정합니다

#3 : 넘겨받은 Sort 객체를 사용해 IndexSearcher의 search 메소드를 호출합니다

#4 : Sort 객체의 toString 메소드를 통해 정렬 관련 정보를 화면에 출력합니다

#5 : 검색 결과를 화면에 표시할 때 UTF-8 인코딩으로 출력하게 별도의 PrintStream 객체를 준비합니다

#6 : 출력을 보기 좋게 만듭니다

 

결과 출력

public static void main(String[] args) throws Exception {
    Query allBooks = new MatchAllDocsQuery();

    QueryParser parser = new QueryParser(Version.LUCENE_30,                 // #1
                                         "contents",                             // #1
                                         new StandardAnalyzer(                   // #1
                                           Version.LUCENE_30));             // #1
    BooleanQuery query = new BooleanQuery();                                     // #1
    query.add(allBooks, BooleanClause.Occur.SHOULD);                             // #1
    query.add(parser.parse("java OR action"), BooleanClause.Occur.SHOULD);       // #1

    Directory directory = TestUtil.getBookIndexDirectory();                     // #2
    SortingExample example = new SortingExample(directory);                     // #2

    example.displayResults(query, Sort.RELEVANCE);

    example.displayResults(query, Sort.INDEXORDER);

    example.displayResults(query, new Sort(new SortField("category", SortField.STRING)));

    example.displayResults(query, new Sort(new SortField("pubmonth", SortField.INT, true)));

    example.displayResults(query,
        new Sort(new SortField("category", SortField.STRING),
                 SortField.FIELD_SCORE,
                 new SortField("pubmonth", SortField.INT, true)
                 ));

    example.displayResults(query, new Sort(new SortField[] {SortField.FIELD_SCORE, new SortField("category", SortField.STRING)}));
    directory.close();
  }

#1 : 텍스트 질의 생성

즉, 색인에 들어있는 전체 문서를 결과로 가져오면서 동시에 일부 문서는 높은 점수를 받게 됩니다

따라서 연관도 점수 순서로 정렬했을 때 예제로 사용하기 적당한 결과를 받아올 수 있습니다

#2 : 도서 정보 색인을 열고 SortingExample 인스턴스를 생성하고 실행합니다

연관도 순서 정렬

루씬은 기본 설정으로 연관도 점수 기준으로 내림차순으로 정렬합니다

기본 설정으로 정렬하려면 기본 search 메소드를 사용하거나, Sort 인자를 null로 지정하면 됩니다

아무 설정 없이 Sort 객체를 생성하면 연관도 점수 순서로 정렬됩니다

Sort.RELEVANCE 설정과 동일합니다

example.displayResults(allBooks, Sort.RELEVANCE);

점수가 동일하면 문서 id가 앞에 있는게 우선 순위로 정렬됩니다

 

색인 순서 정렬

색인한 순서를 기준으로 정렬하려면 Sort.INDEXORDER 정렬 조건을 사용하면 됩니다

전체 대상 문서를 한 번 색인하고 추후 변경되지 않는 경우 의미가 있습니다

필드 값으로 정렬

필드의 값이 문자열인 경우 정렬하려면 하나의 텀만 들어있어야 합니다

보통 정렬하고자 하는 필드에 Field.Index.NOT_ANALYZED나 Field.Index.NOT_ANALYZED_NO_NORMS 설정을 지정하면 단 하나의 텀만 갖게 됩니다

특정 필드 값을 기준으로 정렬하려면 Sort 인스턴스를 생성하고 정렬하고자 하는 필드의 이름을 지정합니다

new Sort(new SortField("category", SortField.STRING));

정렬 순서 변경

기본적으로 자연 정렬 순서를 따릅니다 (연관도의 경우 내림차순, 다른 모든 필드는 오름차순)

역방향으로 정렬하고 싶다면 역방향 인자에 true 값을 넘겨줍니다

new Sort(new SortField("pubmonth", SortField.INT, true))

여러 필드의 값으로 정렬

SortField 인스턴스를 여러 개 지정해 여러 필드에서 정렬할 수 있습니다

new Sort(new SortField("category", SortField.STRING), 
					SortField.FIELD_SCORE, 
					new SortField("pubmonth", SortField.INT, true)

기본 정렬 조건으로 사용하고, 분류가 같은 경우 연관도 점수 순서로 정렬하고, 연관도 점수까지 동일하면 pubmonth 필드 값 기준으로 역순으로 정렬합니다

 

정렬할 로케일 지정

SortField.STRING 자료형의 필드를 정렬할 때 기본적으로 String.compareTo 메소드를 기준으로 정렬 순서가 결정됩니다

바꾸고 싶다면 SortField 객체에 Locale 객체를 지정해 원하는 로케일에 따라 정렬할 수 있습니다

public SortField (String field, Locale locale)
public SortField (String field, Locale locale, boolean reverse)

로케일은 숫자가 아닌 문자열에만 지정되기 때문에 위 메소드 2개는 해당 필드를 문자열 형태로 정렬합니다

MultiPhraseQuery 활용

MulitPhraseQuery는 기본적으로 PhraseQuery와 비슷하게 동작하지만, 한 위치에 여러 텀을 지정할 수 있습니다

MultiPhraseQuery를 쓰지 않고 BooleanQuery로 연결해서 동일한 기능을 사용할 수 있지만 성능 차이가 매우 큽니다

예) quick 또는 fast 다음에 fox 단어가 나타나는 문서를 모두 찾으려고 합니다

"quick fox" OR "fast fox" 질의를 실행하거나 MultiPhraseQuery 질의를 사용할 수 있습니다

public class MultiPhraseQueryTest extends TestCase {
  private IndexSearcher searcher;

  protected void setUp() throws Exception {
    Directory directory = new RAMDirectory();
    IndexWriter writer = new IndexWriter(directory,
                                         new WhitespaceAnalyzer(),
                                         IndexWriter.MaxFieldLength.UNLIMITED);
    Document doc1 = new Document();
    doc1.add(new Field("field",
              "the quick brown fox jumped over the lazy dog",
              Field.Store.YES, Field.Index.ANALYZED));
    writer.addDocument(doc1);
    Document doc2 = new Document();
    doc2.add(new Field("field",
              "the fast fox hopped over the hound",
              Field.Store.YES, Field.Index.ANALYZED));
    writer.addDocument(doc2);
    writer.close();

    searcher = new IndexSearcher(directory);
  }

첫 번째 문서 : "the quick brown fox jumped over the lazy dog"

두 번째 문서 : "the fast fox hopped over the hound"

public void testBasic() throws Exception {
    MultiPhraseQuery query = new MultiPhraseQuery();
    query.add(new Term[] {                       // #A
        new Term("field", "quick"),              // #A
        new Term("field", "fast")                // #A
    });
    query.add(new Term("field", "fox"));         // #B
    System.out.println(query);

    TopDocs hits = searcher.search(query, 10);
    assertEquals("fast fox match", 1, hits.totalHits);

    query.setSlop(1);
    hits = searcher.search(query, 10);
    assertEquals("both match", 2, hits.totalHits);
  }
  /*

#A : 먼저 어느 텀이든 허용합니다

#B : 그 후 단일 텀을 허용

 

PhraseQuery와 동일하게 슬롭 값을 지정할 수 있습니다

두 번째 쿼리에서는 슬롭 1을 설정합니다

BooleanQuery로도 MultiPhraseQuery와 동일한 기능을 구현할 수 있습니다

public void testAgainstOR() throws Exception {
    PhraseQuery quickFox = new PhraseQuery();
    quickFox.setSlop(1);
    quickFox.add(new Term("field", "quick"));
    quickFox.add(new Term("field", "fox"));

    PhraseQuery fastFox = new PhraseQuery();
    fastFox.add(new Term("field", "fast"));
    fastFox.add(new Term("field", "fox"));

    BooleanQuery query = new BooleanQuery();
    query.add(quickFox, BooleanClause.Occur.SHOULD);
    query.add(fastFox, BooleanClause.Occur.SHOULD);
    TopDocs hits = searcher.search(query, 10);
    assertEquals(2, hits.totalHits);
  }

MulitPhraseQuery를 사용하면 슬롭 값이 모든 구문에 적용되지만,

PhraseQuery와 BooleanQuery를 사용하면 해당하는 PhraseQuery에만 슬롭 값이 적용됩니다

 

여러 개의 필드를 동시에 검색

필드에 관계없이 색인에 들어 있는 모든 내용을 검색하고 싶을 때 3가지 방법을 사용할 수 있습니다

첫 번째 방법

모든 필드의 텍스트를 묶어 하나로 만든 별도의 필드를 준비합니다

단점은 필드별 중요도를 지정할 수 없고, 개별 필드의 내용을 모두 색인한다고 하면 색인이 저장되는 디스크 공간을 낭비하는 셈이기도 합니다

두 번째 방법

QueryParser 클래스를 상속받아 작성한 MultiFieldQueryParser를 사용하는 방법입니다

MultiFieldQueryParser는 내부적으로 QueryParser 인스턴스를 생성해 필드별 질의 표현식을 파싱한 다음, 최종적으로 BooleanQuery를 사용해 하나의 질의로 묶습니다

기본 설정으로 OR이고 필요에 따라 MUST, MUST_NOT, SHOULD 등 변경할 수 있습니다

public class MultiFieldQueryParserTest extends TestCase {
  public void testDefaultOperator() throws Exception {
    Query query = new MultiFieldQueryParser(Version.LUCENE_30,
                                            new String[]{"title", "subject"},
        new SimpleAnalyzer()).parse("development");

    Directory dir = TestUtil.getBookIndexDirectory();
    IndexSearcher searcher = new IndexSearcher(
                               dir,
                               true);
    TopDocs hits = searcher.search(query, 10);

    assertTrue(TestUtil.hitsIncludeTitle(
           searcher,
           hits,
           "Ant in Action"));

    assertTrue(TestUtil.hitsIncludeTitle(     //A
           searcher,                          //A
           hits,                              //A
           "Extreme Programming Explained")); //A
    searcher.close();
    dir.close();
  }

  public void testSpecifiedOperator() throws Exception {
    Query query = MultiFieldQueryParser.parse(Version.LUCENE_30,
        "lucene",
        new String[]{"title", "subject"},
        new BooleanClause.Occur[]{BooleanClause.Occur.MUST,
                  BooleanClause.Occur.MUST},
        new SimpleAnalyzer());

    Directory dir = TestUtil.getBookIndexDirectory();
    IndexSearcher searcher = new IndexSearcher(
                               dir,
                               true);
    TopDocs hits = searcher.search(query, 10);

    assertTrue(TestUtil.hitsIncludeTitle(
            searcher,
            hits,
            "Lucene in Action, Second Edition"));
    assertEquals("one and only one", 1, hits.scoreDocs.length);
    searcher.close();
    dir.close();
  }

A : 제목에는 없지만 주제 필드에 development 단어를 담고 있는 문서

 

MultiFieldQueryParser의 주요한 단점 중 하나는 바로 생성된 질의가 상당히 복잡하다는 점입니다

검색할 때마다 각 질의어를 대상 필드에 하나씩 비교해야 하므로 첫 번쨰 방법보다 성능이 떨어질 가능성이 높습니다

세 번째 방법

하나 또는 그 이상의 질의를 묶은 고급 질의인 DisjunctionMaxQuery 클래스를 사용해 검색한 결과 문서를 하나로 합하는 방법입니다

BooleanQuery를 사용해도 동일한 결과를 가져올 수 있지만, DisjunctionMaxQuery는 개별 검색 결과의 점수를 매기는 방법이 매우 독특합니다

 

특정 문서가 하나 이상의 질의에 해당한다면 DisjunctionMaxQuery 질의는 해당하는 각 질의에 대해 계산한 점수 중 최고의 값을 해당 문서의 점수로 사용합니다

하지만, BooleanQuery는 해당하는 질의에 대한 점수의 합을 문서의 점수로 사용합니다

 

첫 번째 방법의 경우 디스크 공간을 낭비하지만 검색 성능으로 보면 가장 빠릅니다

각자 특성이 모두 다르기 때문에 세 방법을 모두 시도해보는것이 좋습니다

 

 

 

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

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

 

 

 

 

댓글