본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 10. 고급 검색 기법(3) - 연관도 점수, 텀 벡터

by 잭피 2021. 1. 26.

함수 질의와 연관도 점수

상황에 따라 연관도 점수 계산 공식을 고치거나 다른 공식으로 바꿔치기해야 할 필요가 있다면 어떻게 해야 할까요?

→ 함수 질의를 사용하여 바꿀 수 있습니다

 

함수 질의 클래스

ValueSourceQuery

모든 함수 질의의 최상위 클래스는 ValueSourceQuery 클래스입니다

각 문서의 연관도 점수는 ValueSourceQuery 인스턴스 생성 시, 지정한 ValueSource 객체를 통해 계산합니다

ValueSourceQuery를 상속받고, 색인된 특정 필드의 값으로 연관도 점수를 계산하는 FieldCacheQuery가 가장 간단한 방법 중 하나입니다

간단한 예제를 살펴볼까요?

doc.add(new Field("score",
                      Integer.toString(score),
                      Field.Store.NO,
                      Field.Index.NOT_ANALYZED_NO_NORMS));

Query q = new FieldScoreQuery("score", FieldScoreQuery.Type.BYTE);

생성된 함수 질의는 색인의 모든 문서를 결과로 추가하며, 연관도 점수로 각 문서의 score 필드 값을 지정합니다

BYTE 대신 SHORT, INT, FLOAT 등의 설정을 지정할 수 있습니다

내부적으로 함수 질의는 필드 캐시를 사용합니다

 

CustomScoreQuery

CustomScoreQuery 클래스를 사용하면 일반 질의와 함수 질의를 원하는 대로 조합해 질의를 구성할 수 있습니다

Query q = new QueryParser(Version.LUCENE_30,
                              "content",
                              new StandardAnalyzer(
                                Version.LUCENE_30))
                 .parse("the green hat");
    FieldScoreQuery qf = new FieldScoreQuery("score",
                                             FieldScoreQuery.Type.BYTE);
    CustomScoreQuery customQ = new CustomScoreQuery(q, qf) {
      public CustomScoreProvider getCustomScoreProvider(IndexReader r) {
        return new CustomScoreProvider(r) {
          public float customScore(int doc,
                                   float subQueryScore,
                                   float valSrcScore) {
            return (float) (Math.sqrt(subQueryScore) * valSrcScore);
          }
        };
      }
    };

return (float) (Math.sqrt(subQueryScore) * valSrcScore);

이렇게 점수를 재정의하여 사용할 수 있습니다

재정의하는 getCustomScoreProvider 메소드에 넘겨주는 IndexReader 인스턴스는 단일 세그먼트를 가리킵니다

따라서 대상 색인에 여러 세그먼트가 여러 개 포함돼 있다면 이 메서드는 여러 번 호출됩니다

 

최근 문서에 중요도를 높게 부여하는 함수 질의

실제 CustomScoreQuery를 주로 사용하는 부분은 바로 문서의 중요도를 동적으로 지정하는 기능입니다

최근에 변경한 문서일수록 중요도를 높게 지정하는 등 어떤 형태의 기준이라도 얼마든지 구현할 수 있습니다

 

예제) 최근 문서의 중요도를 높게 지정하는 기능 테스트

public void testRecency() throws Throwable {
    Directory dir = TestUtil.getBookIndexDirectory();
    IndexReader r = IndexReader.open(dir);
    IndexSearcher s = new IndexSearcher(r);
    s.setDefaultFieldSortScoring(true, true);

    QueryParser parser = new QueryParser(
                            Version.LUCENE_30,
                            "contents",
                            new StandardAnalyzer(
                              Version.LUCENE_30));
    Query q = parser.parse("java in action");       // #A
    Query q2 = new RecencyBoostingQuery(q,          // #B
                                        2.0, 2*365,
                                        "pubmonthAsDay");
    Sort sort = new Sort(new SortField[] {
        SortField.FIELD_SCORE,
        new SortField("title2", SortField.STRING)});
    TopDocs hits = s.search(q2, null, 5, sort);

    for (int i = 0; i < hits.scoreDocs.length; i++) {
      Document doc = r.document(hits.scoreDocs[i].doc);
      System.out.println((1+i) + ": " +
                         doc.get("title") +
                         ": pubmonth=" +
                         doc.get("pubmonth") +
                         " score=" + hits.scoreDocs[i].score);
    }
    s.close();
    r.close();
    dir.close();
  }

  /*
    #A 검색어 표현식 파싱
    #B RecencyBoostingQuery 인스턴스 생성
  */
}

최근 2년 안에 출간된 도서의 경우 중요도를 2배로 지정하게 RecencyBoostingQuery 함수 질의 인스턴스를 생성합니다

다수의 루씬 색인 검색

MultiSearcher

MultiSearcher 클래스를 사용하면 MultiSearcher 안에 포함된 모든 색인을 대상으로 검색하고, 지정된 정렬 조건에 따라 정렬된 검색 결과 집합 하나를 받아올 수 있습니다

 

예제) 키워드의 알파벳을 기준으로 두 개로 분리한 색인을 검색하는 방법

public class MultiSearcherTest extends TestCase {
  private IndexSearcher[] searchers;

  public void setUp() throws Exception {
    String[] animals = { "aardvark", "beaver", "coati",
                       "dog", "elephant", "frog", "gila monster",
                       "horse", "iguana", "javelina", "kangaroo",
                       "lemur", "moose", "nematode", "orca",
                       "python", "quokka", "rat", "scorpion",
                       "tarantula", "uromastyx", "vicuna",
                       "walrus", "xiphias", "yak", "zebra"};

    Analyzer analyzer = new WhitespaceAnalyzer();

    Directory aTOmDirectory = new RAMDirectory();     // #1
    Directory nTOzDirectory = new RAMDirectory();     // #1

    IndexWriter aTOmWriter = new IndexWriter(aTOmDirectory,
                                             analyzer,
                                             IndexWriter.MaxFieldLength.UNLIMITED);
    IndexWriter nTOzWriter = new IndexWriter(nTOzDirectory,
                                             analyzer,
                                             IndexWriter.MaxFieldLength.UNLIMITED);
    

    for (int i=animals.length - 1; i >= 0; i--) {
      Document doc = new Document();
      String animal = animals[i];
      doc.add(new Field("animal", animal, Field.Store.YES, Field.Index.NOT_ANALYZED));
      if (animal.charAt(0) < 'n') {
        aTOmWriter.addDocument(doc);                 // #2
      } else {                                       
        nTOzWriter.addDocument(doc);                 // #2
      }
    }

    aTOmWriter.close();
    nTOzWriter.close();

    searchers = new IndexSearcher[2];
    searchers[0] = new IndexSearcher(aTOmDirectory);
    searchers[1] = new IndexSearcher(nTOzDirectory);
  }

  public void testMulti() throws Exception {

    MultiSearcher searcher = new MultiSearcher(searchers);

    TermRangeQuery query = new TermRangeQuery("animal",   // #3
                                              "h",        // #3
                                              "t",        // #3
                                              true, true);// #3

    TopDocs hits = searcher.search(query, 10);
    assertEquals("tarantula not included", 12, hits.totalHits);
  }

#1 : 디렉토리 두 개 생성

#2 : 알파벳에 따라 두 개의 색인으로 구분해 추가

#3 : 양쪽 색인에 걸치게 검색

 

두 개의 색인을 생성합니다

알파벳 a부터 m까지의 동물은 하나의 색인에 들어가고, n~z까지의 동물이 또 다른 색인에 들어갑니다

그리고 양쪽 색인을 대상으로 h부터 t까지의 동물을 검색합니다

양쪽 범위 끝 값을 포함하는 TermRangeQuery를 사용하면 양쪽 색인에서 h부터 시작해 t로 시작하는 동물까지 결과로 찾아냅니다

스레드를 활용하는 ParallelMultiSearcher

ParallelMultiSearcher 클래스는 MultiSearcher와 같은 기능을 갖고 있지만 스레드를 활용해 검색합니다

각 Searchable 인스턴스마다 스레드를 생성해 실행하고 검색이 모두 끝날 때까지 기다렸다가 기다렸다가 결과를 취합해 하나로 리턴합니다

기본 검색 기능과 필터를 활용한 검색 기능은 스레드를 사용하지만, Collector를 통해 검색하는 메소드는 스레드를 활용하지 않습니다

검색 애플리케이션 마다 직접 성능 테스트를 해보고 사용하는 편이 좋습니다

텀 벡터 활용

텀 벡터는 문서 내부로 한정된 역파일 색인이라고 볼 수 있는 고급 정보입니다

색인할 때 텀 벡터를 생성해 저장한 경우 검색 시점에 할 수 있는 일을 살펴보겠습니다

2가지 예제를 보겠습니다 (특정 문서와 비슷한 문서를 찾는 예제, 문서를 자동으로 분류하는 예제)

기술적으로 텀 벡터는 텀 빈도수의 쌍입니다 (추가적으로 각 텀의 위치 정보를 담고 있을 수도 있습니다)

cat과 dog 2개의 단어만을 담고 있는 문서 두 개를 생각해봅시다

cat, dog 모두 각 문서에서 여러 번 사용하고 있습니다

 

특정 문서의 필드에 대한 텀 벡터를 가져오려면 IndexReader의 메소드를 호출합니다

TermFreqVector vector =                                       
        reader.getTermFreqVector(id, "subject");

TermFreqVector 인스턴스에는 텀 벡터 관련 정보를 알려주는 여러 가지 메소드가 들어있습니다

String, int 값으로 구성된 배열을 가져오는 메소드가 중요합니다 (텀 자체와 필드 안에서 해당 텀의 빈도수를 뜻 합니다)

 

TermPositonVector 클래스는 문서 안에서 각 텀의 시작지점이나 끝 지점과 토큰 위치 정보를 담고 있습니다

 

비슷한 책 조회

예제) 도서 정보 색인에서 특정 책과 비슷한 내용의 책을 찾아주는 코드

public class BooksLikeThis {

  public static void main(String[] args) throws IOException {
    Directory dir = TestUtil.getBookIndexDirectory();

    IndexReader reader = IndexReader.open(dir);
    int numDocs = reader.maxDoc();

    BooksLikeThis blt = new BooksLikeThis(reader);
    for (int i = 0; i < numDocs; i++) {                    // #1
      System.out.println();
      Document doc = reader.document(i);
      System.out.println(doc.get("title"));

      Document[] docs = blt.docsLike(i, 10);               // #2
      if (docs.length == 0) {
        System.out.println("  None like this");
      }
      for (Document likeThisDoc : docs) {
        System.out.println("  -> " + likeThisDoc.get("title"));
      }
    }
    reader.close();
    dir.close();
  }

  private IndexReader reader;
  private IndexSearcher searcher;

  public BooksLikeThis(IndexReader reader) {
    this.reader = reader;
    searcher = new IndexSearcher(reader);
  }

  public Document[] docsLike(int id, int max) throws IOException {
    Document doc = reader.document(id);

    String[] authors = doc.getValues("author");
    BooleanQuery authorQuery = new BooleanQuery();                 // #3
    for (String author : authors) {                     // #3
      authorQuery.add(new TermQuery(new Term("author", author)),   // #3
          BooleanClause.Occur.SHOULD);                             // #3
    }
    authorQuery.setBoost(2.0f);

    TermFreqVector vector =                                        // #4
        reader.getTermFreqVector(id, "subject");                   // #4

    BooleanQuery subjectQuery = new BooleanQuery();                // #4
    for (String vecTerm : vector.getTerms()) {                      // #4
      TermQuery tq = new TermQuery(                                // #4
          new Term("subject", vecTerm));              // #4
      subjectQuery.add(tq, BooleanClause.Occur.SHOULD);            // #4
    }

    BooleanQuery likeThisQuery = new BooleanQuery();               // #5
    likeThisQuery.add(authorQuery, BooleanClause.Occur.SHOULD);    // #5
    likeThisQuery.add(subjectQuery, BooleanClause.Occur.SHOULD);   // #5

    likeThisQuery.add(new TermQuery(                                       // #6
        new Term("isbn", doc.get("isbn"))), BooleanClause.Occur.MUST_NOT); // #6

    // System.out.println("  Query: " +
    //    likeThisQuery.toString("contents"));
    TopDocs hits = searcher.search(likeThisQuery, 10);
    int size = max;
    if (max > hits.scoreDocs.length) size = hits.scoreDocs.length;

    Document[] docs = new Document[size];
    for (int i = 0; i < size; i++) {
      docs[i] = reader.document(hits.scoreDocs[i].doc);
    }

    return docs;
  }
}
/*
#1 Iterate over every book
#2 Look up books like this
#3 Boosts books by same author
#4 Use terms from "subject" term vectors 
#5 Create final query
#6 Exclude current book
*/

#1 : 예제로 도서 정보 색인의 모든 책마다 그와 비슷한 책을 찾아 화면에 출력합니다

#2: 현재 문서와 비슷한 책을 찾아 배열로 받아옵니다

#3: 동일한 저자의 책이라면 훨씬 비슷할 것이라고 판단하고, 비슷한 내용이지만 저자가 다른 책보다 중요도를 높게 지정합니다

#4: subject 필드의 텀 벡터에서 받아온 텀을 BooleanQuery 질의 안에 검색어로 추가합니다

#5: 저자와 주제 질의를 하나의 질의로 통합합니다

#6: 현재 책은 제외하고 검색합니다 참고로 현재 책은 지금까지 지정된 조건으로 볼 때 당연히 가장 높은 점수를 받게 됩니다

 

#3) 색인할 때는 아래처럼 저자 문자열을 여러 개의 Field 인스턴스로 구분해 문서에 추가해줬습니다

String[] authors = author.split(",");            
    for (String a : authors) {                       
      doc.add(new Field("author",                    
                        a,                           
                        Field.Store.YES,             
                        Field.Index.NOT_ANALYZED,    
                        Field.TermVector.WITH_POSITIONS_OFFSETS));   
    }

실행하면 각기 다른 책이 서로 관련된 내용을 담고 있는지 한눈에 알 수 있습니다

이렇게 비슷한 책을 찾아주는 프로그램은 텀 벡터 없이는 구현할 수 없습니다

사실 텀 벡터를 벡터로 활용하고 있지도 않습니다

그저 지정된 필드에 속한 텀을 받아 오는 방법으로 텀 벡터를 활용하고 있습니다

 

자동 분류

텀 벡터를 활용해 자동으로 문서를 분류해주는 기능을 구현할 수 있습니다

도서 정보 색인에 들어있는 문서에는 각자 분류가 지정돼 있습니다

ex) "/technology/computers/programming"

 

이미 존재하던 각 분류의 주제를 대표하는 벡터를 계산하는 코드를 작성합니다

즉, 특정 분류에 속한 모든 문서에서 subject 필드의 텀 벡터를 모두 합해 해당 주제의 대표 벡터로 정합니다

 

분류별 대표 벡터를 준비한 다음, 새로 추가하는 책의 subject 필드 내용으로 텀 벡터를 생성해 대표 벡터와 비교하면 새 문서와 가장 비슷한 분류를 찾아낼 수 있습니다

public void testCategorization() throws Exception {
    assertEquals("/technology/computers/programming/methodology",
        getCategory("extreme agile methodology"));
    assertEquals("/education/pedagogy",
        getCategory("montessori education philosophy"));
  }

새로 추가하는 책의 subject 필드에 "extreme agile methodology"라는 키워드가 있었다면 가장 적절한 분류는 "/technology/computers/programming/methodology"라고 찾아내는지 확인하는 코드입니다

 

즉, 벡터 공간에서 새로 추가하는 책의 분류는 해당 책의 subject 텀 벡터와 가장 각도가 가까운 대표 벡터의 분류로 결정하기 때문입니다

 

색인된 모든 문서를 하나씩 읽어가면서 subject 필드의 텀 벡터를 분류마다 하나로 취합합니다

분류별 대표 벡터는 맵 객체에 분류 이름을 키로 사용하며,

텀을 키로, 값을 빈도수로 담고 있는 또 다른 맵 인스턴스를 보관합니다

 

예제) 분류별 대표 벡터 생성

private void buildCategoryVectors() throws IOException {
    IndexReader reader = IndexReader.open(TestUtil.getBookIndexDirectory());

    int maxDoc = reader.maxDoc();

    for (int i = 0; i < maxDoc; i++) {
      if (!reader.isDeleted(i)) {
        Document doc = reader.document(i);
        String category = doc.get("category");

        Map vectorMap = (Map) categoryMap.get(category);
        if (vectorMap == null) {
          vectorMap = new TreeMap();
          categoryMap.put(category, vectorMap);
        }

        TermFreqVector termFreqVector =
            reader.getTermFreqVector(i, "subject");

        addTermFreqToMap(vectorMap, termFreqVector);
      }
    }
  }

addTermFreqToMap 메소드는 추가된 책의 텀 빈도수 벡터를 분류 대표 벡터에 추가합니다

getTerms() 메소드와 getTermFrequencies() 메소드로 가져온 배열은 크기가 같으며,

동일한 번호의 항목이 텀과 빈도수로 하나의 쌍을 이룹니다

 

예제) 추가된 문서의 텀 벡터를 대표 벡터에 합산

private void addTermFreqToMap(Map vectorMap,
                                TermFreqVector termFreqVector) {
    String[] terms = termFreqVector.getTerms();
    int[] freqs = termFreqVector.getTermFrequencies();

    for (int i = 0; i < terms.length; i++) {
      String term = terms[i];

      if (vectorMap.containsKey(term)) {
        Integer value = (Integer) vectorMap.get(term);
        vectorMap.put(term,
            new Integer(value.intValue() + freqs[i]));
      } else {
        vectorMap.put(term, new Integer(freqs[i]));
      }
    }
  }

 

 

FieldSelector로 필드 선택

IndexReader를 사용해 색인에 들어있는 문서를 읽어올 수 있습니다

내부적으로 보면 Field.Store.YES라고 설정된 필드의 원문을 색인에 그대로 보관하고,

필요한 경우 IndexReader를 통해 색인에 보관된 값을 읽어오는 형태로 동작합니다

색인에서 문서를 읽어오는 일은 성능을 떨어뜨리는 일입니다

FieldSelector를 사용하면 색인에서 문서마다 원하는 필드만 읽어올 수 있습니다

FieldSelectorResult accept(String fieldName);

FieldSelector를 사용하면 색인에서 문서를 불러올 때 걸리는 시간은 단축시킬 수 있겠지만, 단축되는 시간은 애플리케이션에 따라 다를 수 있습니다

저장된 필드의 원문을 불러오는 일은 대부분 해당하는 필드가 저장된 디스크의 위치로 디스크의 헤드를 이동하는 시간이 대부분이기 때문에 속도가 그다지 빨리지지 않을 수 있습니다

 

검색 중단

일반적으로 루씬의 검색은 매우 빠르게 실행됩니다

매우 규모가 큰 색인을 사용하거나, 복잡한 질의를 사용해 검색하는 상황이라면 검색 소도가 크게 느려질 가능성이 있습니다

Collector 하위 클래스인 TimeLimitingCollector 클래스는 지정한 시간이 지나면 검색을 중단하는 기능을 갖고 있습니다

검색 시간이 너무 오래걸리면 TimeExceededException 예외를 발생시킵니다

public class TimeLimitingCollectorTest extends TestCase {
  public void testTimeLimitingCollector() throws Exception {
    Directory dir = TestUtil.getBookIndexDirectory();
    IndexSearcher searcher = new IndexSearcher(dir);
    Query q = new MatchAllDocsQuery();
    int numAllBooks = TestUtil.hitCount(searcher, q);

    TopScoreDocCollector topDocs = TopScoreDocCollector.create(10, false);
    Collector collector = new TimeLimitingCollector(topDocs,  // #A
                                                    1000);    // #A
    try {
      searcher.search(q, collector);
      assertEquals(numAllBooks, topDocs.getTotalHits());  // #B
    } catch (TimeExceededException tee) {                 // #C
      System.out.println("Too much time taken.");         // #C
    }                                                     // #C
    searcher.close();
    dir.close();
  }
}

/*
  #A 기존의 Collector 인스턴스를 그대로 사용
  #B 결과 개수 확인
  #C 타임 아웃 초과 메시지 
*/

TimeLimitingCollector를 사용할 때 주의사항이 몇 가지 있습니다

제한된 시간을 초과했는지 확인하는 기능으로 추가적인 부하가 걸리기 때문에 이론적으로 검색 속도가 조금 느려질 수 있습니다

그리고 검색 결과를 수집하는 도중에만 제한된 시간을 확인하기 때문에 Query.rewite() 메소드를 실행하는 데 시간이 너무 오래 걸리는 경우에는 해당되지 않습니다

댓글