Today I Learned

[루씬 인 액션] 6장. 검색 기능 확장 본문

루씬 Lucene

[루씬 인 액션] 6장. 검색 기능 확장

하이라이터 2021. 1. 26. 01:11
728x90

6장에서 다루는 내용

- 정렬 기능 직접 구현

- Collector 활용

- QueryParser 기능 변경

- 위치 기반 적재


6.1 정렬 기능 직접 구현

FieldComparatorSource 클래스

- 검색 결과를 연관도 점수, 문서 ID, 특정 필드의 값 등이 아닌 다른 값으로 정렬해야 할 때, 상속받아 직접 구현할 수 있다.

- 색인 과정에서 정렬 조건을 확정할 수 없는 경우 검색 결과를 원하는 방법으로 정렬하는 기능 사용시 유용하다.

 

검색 결과를 특정 지점에서 지리적인 거리가 얼마나 가까운지를 기준으로 정렬하는 방법으로 알아보자.

1. 색인할 때 준비해야할 내용

2. 검색할 때 정렬 기능을 구현하는 방법

3. 화면에 표시할 목적으로 색인된 문서의 위치정보를 찾아내는 방법

 

6.1.1 색인 시점에 필요한 준비

예제 6.1 지리 정보 색인

- 각 식당의 이름과 종류, 위치(X,Y 좌표) 색인

- 식당 이외의 다른 유형의 매장도 손쉽게 추가 가능

public class DistanceSortingTest extends TestCase {
  private RAMDirectory directory;
  private IndexSearcher searcher;
  private Query query;

  protected void setUp() throws Exception {
    directory = new RAMDirectory();
    IndexWriter writer =
        new IndexWriter(directory, new WhitespaceAnalyzer(),
                        IndexWriter.MaxFieldLength.UNLIMITED);
    addPoint(writer, "El Charro", "restaurant", 1, 2);
    addPoint(writer, "Cafe Poca Cosa", "restaurant", 5, 9);
    addPoint(writer, "Los Betos", "restaurant", 9, 6);
    addPoint(writer, "Nico's Taco Shop", "restaurant", 3, 8);

    writer.close();

    searcher = new IndexSearcher(directory);

    query = new TermQuery(new Term("type", "restaurant"));
  }

  private void addPoint(IndexWriter writer,
                        String name, String type, int x, int y)
      throws IOException {
    Document doc = new Document();
    doc.add(new Field("name", name, Field.Store.YES, Field.Index.NOT_ANALYZED));
    doc.add(new Field("type", type, Field.Store.YES, Field.Index.NOT_ANALYZED));
    doc.add(new Field("x", Integer.toString(x), Field.Store.YES, Field.Index.NOT_ANALYZED_NO_NORMS));
    doc.add(new Field("y", Integer.toString(y), Field.Store.YES, Field.Index.NOT_ANALYZED_NO_NORMS));
    writer.addDocument(doc);
  }
}

- 위치 좌표는 하나의 필드에 x,y 형태의 문자열로 색인

 

6.1.2 거리 기준 정렬 기능 구현

먼저 정렬 기능이 올바르게 동작하는지 테스트하는 메소드를 작성해보자.

  public void testNearestRestaurantToHome() throws Exception {
    Sort sort = new Sort(new SortField("unused",
        new DistanceComparatorSource(0, 0)));

    TopDocs hits = searcher.search(query, null, 10, sort);

    assertEquals("closest",
                 "El Charro", searcher.doc(hits.scoreDocs[0].doc).get("name"));
    assertEquals("furthest",
                 "Los Betos", searcher.doc(hits.scoreDocs[3].doc).get("name"));
  }

집의 좌표는 (0,0)이고, 검색 결과 중 가장 가까운 식당과 가장 먼 식당이 정확한지만 확인헀다.

실제 두 지점 간의 거리는 예제 6.2에서 기본적인 거리 계산 공식을 사용해 계산한다.

 

예제 6.2 DistanceComparatorSource

public class DistanceComparatorSource
  extends FieldComparatorSource {                 // #1
  private int x;
  private int y;

  public DistanceComparatorSource(int x, int y) { // #2
    this.x = x;
    this.y = y;
  }

  public FieldComparator newComparator(java.lang.String fieldName,   // #3
                                       int numHits, int sortPos,   // #3
                                       boolean reversed)   // #3
    throws IOException {       // #3
    return new DistanceScoreDocLookupComparator(fieldName,
                                                numHits);
  }

  private class DistanceScoreDocLookupComparator  // #4
      extends FieldComparator {
    private int[] xDoc, yDoc;                     // #5
    private float[] values;                       // #6
    private float bottom;                         // #7
    String fieldName;

    public DistanceScoreDocLookupComparator(
                  String fieldName, int numHits) throws IOException {
      values = new float[numHits];
      this.fieldName = fieldName;
    }

    public void setNextReader(IndexReader reader, int docBase) throws IOException {
      xDoc = FieldCache.DEFAULT.getInts(reader, "x");  // #8
      yDoc = FieldCache.DEFAULT.getInts(reader, "y");  // #8
    }

    private float getDistance(int doc) {              // #9
      int deltax = xDoc[doc] - x;                     // #9
      int deltay = yDoc[doc] - y;                     // #9
      return (float) Math.sqrt(deltax * deltax + deltay * deltay); // #9
    }

    public int compare(int slot1, int slot2) {          // #10
      if (values[slot1] < values[slot2]) return -1;     // #10
      if (values[slot1] > values[slot2]) return 1;      // #10
      return 0;                                         // #10
    }

    public void setBottom(int slot) {                   // #11
      bottom = values[slot];
    }

    public int compareBottom(int doc) {                 // #12
      float docDistance = getDistance(doc);
      if (bottom < docDistance) return -1;              // #12
      if (bottom > docDistance) return 1;               // #12
      return 0;                                         // #12
    }

    public void copy(int slot, int doc) {               // #13
      values[slot] = getDistance(doc);                  // #13
    }

    public Comparable value(int slot) {                 // #14
      return new Float(values[slot]);                   // #14
    }                                                   // #14

    public int sortType() {
      return SortField.CUSTOM;
    }
  }

  public String toString() {
    return "Distance from ("+x+","+y+")";
  }
}

- 루씬 내부에저 정렬할 때 FieldComparatorSource(#1)와 FieldComparator(#4) API를 함께 사용한다.

- FieldComparator는 루씬에서 찾아내 관리하는 큐의 크기를 알고있어야 한다.(newComparator 메소드에 전달되는 numHits 인자)(#3)

- 검색 과정에 다음 세그먼트로 넘어갈 때마다 FieldComparator의 setNextReader 메소드를 호출한다.

- 생성 메소드에는 거리를 비교할 기준 위치의 자료를 인자로 지정한다.(#2)

- setNextReader 메소드가 호출될 때마다 해당 세그먼트의 필드 캐시에서 식당의 좌표를 모두 확보한다.(#5,#8)

- 필드 캐시에서 확보한 좌표 값은 getDistance 메소드에서 해당 문서와의 거리를 계산할 때 활용한다.(#9)

- 정렬할 때 필요한 실제 값을 확보하고자 루씬에서 value 메소드를 호출한다.(#14)

- 검색 시 특정 문서가 결과에 포함된다고 판단하면 해당 문서를 큐에 추가한다.

- compare 메소드 : 큐 안에 쌓여있는 결과를 비교할때(#10)

- setBottom 메소드 : 최하위 항목을 설정할때(#7,#11)

- compareBottom 메소드 : 검색 결과를 최하위 항목과 비교할 때(#12)

- copy 메소드 : 검색 결과를 큐에 추가할 때(#11)

- value 배열 : 큐에 쌓여있는 모든 문서에 대해 계산한 거리를 보관(#6)

 

6.1.3 정렬할 때 계산한 값 활용

IndexSearcher.search 메소드를 사용해 최상위 문서 외에 좀 더 많은 정보를 받아올 수 있다.

public TopFielDocs search(Query query, Filter filter, int nDocs, Sort sort)

TopFieldDocs 클래스 : 정렬할 때 사용했던 값이 각 결과 항목에 포함

 

예제 6.3 정렬할 때 계산한 값 확보

사무실의 위치(10,10)에서 각 식당까지의 거리를 받아보자.

public void testNeareastRestaurantToWork() throws Exception {
    Sort sort = new Sort(new SortField("unused",
        new DistanceComparatorSource(10, 10)));

    TopFieldDocs docs = searcher.search(query, null, 3, sort);  // #1

    assertEquals(4, docs.totalHits);              // #2
    assertEquals(3, docs.scoreDocs.length);       // #3

    FieldDoc fieldDoc = (FieldDoc) docs.scoreDocs[0];     // #4

    assertEquals("(10,10) -> (9,6) = sqrt(17)",
        new Float(Math.sqrt(17)),
        fieldDoc.fields[0]);                         // #5

    Document document = searcher.doc(fieldDoc.doc);  // #6
    assertEquals("Los Betos", document.get("name"));

    //dumpDocs(sort, docs);
  }

(#1) 찾아오려는 결과의 최대 개수를 지정

(#2) 전체 결과의 개수 확인

(#3) 결과 집합에 담겨있는 문서의 개수(지정한 최대값 이하)

(#4) docs.scoreDocs(0) 메소드는 ScoreDoc 인스턴스를 리턴하며, 정렬할 때 계산한 값을 구하기 위해서는 FieldDoc로 형변환 해야함

(#5) 정렬 조건으로 지정된 SortField가 들어있는 fields 배열

(#6) 실제 원문인 Documents 객체를 받아오려면 IndexSearcher 메소드를 한번 더 호출해야함


6.2 Collector 클래스 직접 구현

6.2.1 Collector 클래스

Collector 클래스

- 루씬에서 검색할 때 결과를 받아오는 기능과 관련한 API를 정의하는 추상 클래스

- 루씬의 핵심 검색 관련 기능 역시 내부적으로 Collector의 하위 클래스로 구현됨

  ex) 연관도 정렬(TopScoreDocCollector), 특정 필드 기준 정렬(TopFieldCollector)

 

collect(int docID) 메소드

- 루씬이 검색 조건에 해당하는 문서를 찾아낼 시 호출되어 검색 결과를 기록하는 등의 모든 일을 위임받음

- 문서 ID만 전달하고 연관도 점수는 전달하지 않음(연관도 점수가 필요치 않은 Collector를 위함)

 

setNextReader(IndexReader reader, int docBase) 메소드

- 루씬은 성능을 위해 한번에 세그먼트 하나씩만 검색하며, 다음 세그먼트로 넘어갈 때 호출됨

 

setScorer(Scorer) 메소드

- 연관도 점수를 계산하는 Scorer 인스턴스를 넘겨줌

- 캐시 등의 기능이 없기 때문에 호출시마다 새로 계산

 

acceptDocsOutOfOrder() 메소드

- 문서 ID의 순서가 지켜져야하는지에 따라 true(순서X)/false(순서O) 값을 리턴

 

6.2.2 Collector 직접 구현 : BookLinkCollector

예제 6.4 모든 검색 결과의 링크와 제목을 확보하게 직접 구현한 Collector

public class BookLinkCollector extends Collector {
  private Map<String,String> documents = new HashMap<String,String>();
  private Scorer scorer;
  private String[] urls;
  private String[] titles;

  public boolean acceptsDocsOutOfOrder() {
    return true;                            // 문서 ID 순서에 상관 X
  }

  public void setScorer(Scorer scorer) {
    this.scorer = scorer;
  }

  public void setNextReader(IndexReader reader, int docBase) throws IOException {
    urls = FieldCache.DEFAULT.getStrings(reader, "url");           // 필드 캐시를 
    titles = FieldCache.DEFAULT.getStrings(reader, "title2");      // 불러옴
  }

  public void collect(int docID) {
    try {
      String url = urls[docID];            // 검색 결과 문서에
      String title = titles[docID];        // 해당하는 URL과
      documents.put(url, title);           // 제목 확보
      System.out.println(title + ":" + scorer.score());
    } catch (IOException e) {
      // ignore
    }
  }

  public Map<String,String> getLinks() {
    return Collections.unmodifiableMap(documents);
  }
}

검색 결과 문서마다 문서 ID 대신, URL과 제목을 내부의 Map 인스턴스에 보관한다.

urls와 titles 배열은 세그먼트별 필드 캐시를 활용하며,

문서 ID를 사용하지 않기 때문에 setNextReader 메소드를 통해 넘겨받은 docBase 값은 무시한다.

 

예제 6.5 BookLinkCollector 테스트

  public void testCollecting() throws Exception {
    Directory dir = TestUtil.getBookIndexDirectory();
    TermQuery query = new TermQuery(new Term("contents", "junit"));
    IndexSearcher searcher = new IndexSearcher(dir);

    BookLinkCollector collector = new BookLinkCollector();
    searcher.search(query, collector);

    Map<String,String> linkMap = collector.getLinks();
    assertEquals("ant in action",
                 linkMap.get("http://www.manning.com/loughran"));

    TopDocs hits = searcher.search(query, 10);
    TestUtil.dumpHits(searcher, hits);

    searcher.close();
    dir.close();
  }

 

6.2.3 AllDocCollector

검색 결과의 건수가 아주 많지 않다고 에상되는 경우 검색 결과로 찾아낸 모든 문서를 기록할 수 있다.

 

예제 6.6 검색 결과 문서와 연관도 점수를 List 객체에 모두 담는 Collector

public class AllDocCollector extends Collector {
  List<ScoreDoc> docs = new ArrayList<ScoreDoc>();
  private Scorer scorer;
  private int docBase;

  public boolean acceptsDocsOutOfOrder() {
    return true;
  }

  public void setScorer(Scorer scorer) {
    this.scorer = scorer;
  }

  public void setNextReader(IndexReader reader, int docBase) {
    this.docBase = docBase;
  }

  public void collect(int doc) throws IOException {
    docs.add(
      new ScoreDoc(doc+docBase,         
                   scorer.score()));
  }

  public void reset() {
    docs.clear();
  }

  public List<ScoreDoc> getHits() {
    return docs;
  }
}

6.3 QueryParser 확장

QueryParser 클래스를 통해 날짜를 파싱할 때 로케일을 지정하거나, 기본적으로 사용할 구문 질의의 슬롭 값 등을 지정할 수 있다.

이러한 설정뿐만 아니라 QueryParser를 상속받아 질의를 생성하는 기능 자체를 변경할 수 있다.

 

6.3.1 QueryParser의 기능 변경

표 6.2에서 나열한 메소드는 Query 객체를 리턴하며, 따라서 원하는 메소드를 구현해 오버라이드하면 원래 QueryParser에서 만들어주던 질의와 다른 질의를 만들 수 있다.

그리고 문제가 있으면 여전히 ParseException을 발생시켜 오류를 처리할 수 있다.

 

6.3.2 퍼지와 와일드 카드 질의 제한

QueryParser를 상속받아 퍼지 질의와 와일드카드 질의를 사용하지 못하게 제한한 예제를 소개한다.

 

예제 6.7 와일드카드와 퍼지 질의 제한

public class CustomQueryParser extends QueryParser {
  public CustomQueryParser(Version matchVersion, String field, Analyzer analyzer) {
    super(matchVersion, field, analyzer);
  }

  protected final Query getWildcardQuery(String field, String termStr) throws ParseException {
    throw new ParseException("Wildcard not allowed");
  }

  protected Query getFuzzyQuery(String field, String term, float minSimilarity) throws ParseException {
    throw new ParseException("Fuzzy queries not allowed");
  }
}

 

예제 6.8 직접 작성한 QueryParser 활용

  public void testCustomQueryParser() {
    CustomQueryParser parser =
      new CustomQueryParser(Version.LUCENE_30,
                            "field", analyzer);
    try {
      parser.parse("a?t");
      fail("Wildcard queries should not be allowed");
    } catch (ParseException expected) {
                                         //예외가 발생해야 정상
    }

    try {
      parser.parse("xunit~");
      fail("Fuzzy queries should not be allowed");
    } catch (ParseException expected) {
                                         //예외가 발생해야 정상
    }
  }

 

6.3.3 숫자 범위 질의 처리

숫자 범위에 대해 NumericRangeQuery를 생성하게 확장해보자.

 

예제 6.9 숫자 필드를 올바로 다루게 QueryParser 확장

  static class NumericRangeQueryParser extends QueryParser {
    public NumericRangeQueryParser(Version matchVersion,
                                   String field, Analyzer a) {
      super(matchVersion, field, a);
    }
    public Query getRangeQuery(String field,
                               String part1,
                               String part2,
                               boolean inclusive)
        throws ParseException {
      TermRangeQuery query = (TermRangeQuery)            // 상위 클래스에서
        super.getRangeQuery(field, part1, part2,         // 기본적으로 생성하는
                              inclusive);                // TermRangeQuery 확보
      if ("price".equals(field)) {
        return NumericRangeQuery.newDoubleRange(         // 검색어에 해당하는
                      "price",                           // NumericRangeQuery
                      Double.parseDouble(                // 인스턴스를 생성해
                           query.getLowerTerm()),        // 리턴
                      Double.parseDouble(                // 
                           query.getUpperTerm()),        // 
                      query.includesLower(),             // 
                      query.includesUpper());            // 
      } else {
        return query;                                    // 원래 생성했던 TermRangeQuery 리턴
      }
    }
  }

특정 조건에 해당하는 경우에는 NumericRangeQuery를 생성하고, 그 외의 경우에는 TermRangeQuery를 생성한다.

 

다음과 같이 사용할 수 있다.

  public void testNumericRangeQuery() throws Exception {
    String expression = "price:[10 TO 20]";

    QueryParser parser = new NumericRangeQueryParser(Version.LUCENE_30,
                                                     "subject", analyzer);

    Query query = parser.parse(expression);
    System.out.println(expression + " parsed to " + query);
  }

실행결과

6.3.4 날짜 범위 질의 처리

QueryParser는 기본적인 날짜 범위 인식 기능을 제공한다.

DataFormat.SHORT 형식의 날짜 → 로케일에 맞춰 문자열 파싱 → DataField.dateToString 메소드로 문자열 날짜 표현식으로 변환

 

이제 NumericField 클래스를 통해 색인된 날짜 필드도 처리할 수 있도록 QueryParser를 확장해보자.

 

예제 6.10 날짜 필드를 처리하게 확장한 QueryParser

public static class NumericDateRangeQueryParser extends QueryParser {
    public NumericDateRangeQueryParser(Version matchVersion,
                                       String field, Analyzer a) {
      super(matchVersion, field, a);
    }
    public Query getRangeQuery(String field,
                               String part1,
                               String part2,
                               boolean inclusive)
      throws ParseException {
      TermRangeQuery query = (TermRangeQuery)
          super.getRangeQuery(field, part1, part2, inclusive);

      if ("pubmonth".equals(field)) {
        return NumericRangeQuery.newIntRange(
                    "pubmonth",
                    Integer.parseInt(query.getLowerTerm()),
                    Integer.parseInt(query.getUpperTerm()),
                    query.includesLower(),
                    query.includesUpper());
      } else {
        return query;
      }
    }
  }

QueryParser에 내장된 날짜 판별 기능과 파싱 기능은 그대로 사용하지만, pubMonth 필드인 경우 숫자로 간주에 NumericRangeQuery 객체를 생성한다.

 

예제 6.11 날짜 범위 파싱 기능 테스트

  public void testDateRangeQuery() throws Exception {
    String expression = "pubmonth:[01/01/2010 TO 06/01/2010]";

    QueryParser parser = new NumericDateRangeQueryParser(Version.LUCENE_30,
                                                         "subject", analyzer);
    
    parser.setDateResolution("pubmonth", DateTools.Resolution.MONTH);    // 1
    parser.setLocale(Locale.US);

    Query query = parser.parse(expression);
    System.out.println(expression + " parsed to " + query);

    TopDocs matches = searcher.search(query, 10);
    assertTrue("expecting at least one result !", matches.totalHits > 0);
  }

실행결과

 

날짜 파싱 로케일 제어

QueryParser의 setLocale() 메소드로 날짜 검색어를 파싱할 때 사용할 로케일을 변경할 수 있다.

일반적으로 기본 로케일 대신 클라이언트 측의 로케일을 파악해 사용하는 경우가 많다.

 

예제 6.12 웹 애플리케이션에서 클라이언트 브라우저의 로케일을 확인하고 QueryParser에 적용

public class SearchServletFragment extends HttpServlet {

  private IndexSearcher searcher;

  protected void doGet(HttpServletRequest request,
                       HttpServletResponse response) 
      throws ServletException, IOException {
    
    QueryParser parser = new NumericDateRangeQueryParser(Version.LUCENE_30,
                                                  "contents",
        new StandardAnalyzer(Version.LUCENE_30));
    
    parser.setLocale(request.getLocale());
    parser.setDateResolution(DateTools.Resolution.DAY);

    Query query = null;
    try {
      query = parser.parse(request.getParameter("q"));
    } catch (ParseException e) {
      e.printStackTrace(System.err);  // 예외처리
    }

    TopDocs docs = searcher.search(query, 10);        // 검색하고 결과 출력
  }
}

 

6.3.5 순서가 정해진 구문 질의

PhraseQuery : 지정된 슬롭 값이 적절한 경우 원문에서 텀의 순서가 뒤바뀐 문서도 결과로 찾아낸다.

SpanNearQuery : 텀의 순서를 그대로 지키는 문서만 찾아낸다.  

 

getFieldQuery 메소드를 오버라이드해서 PhraseQuery 대신 SpanNearQuery를 생성하도록 변경해보자.

 

예제 6.13 PhraseQuery 대신 SpanNearQuery를 생성

  protected Query getFieldQuery(String field, String queryText, int slop) throws ParseException {
    Query orig = super.getFieldQuery(field, queryText, slop);  // #1

    if (!(orig instanceof PhraseQuery)) {         // #2
      return orig;                                // #2
    }                                             // #2

    PhraseQuery pq = (PhraseQuery) orig;
    Term[] terms = pq.getTerms();                 // #3
    SpanTermQuery[] clauses = new SpanTermQuery[terms.length];
    for (int i = 0; i < terms.length; i++) {
      clauses[i] = new SpanTermQuery(terms[i]);
    }

    SpanNearQuery query = new SpanNearQuery(      // #4
                    clauses, slop, true);         // #4

    return query;
  }

(#1) QueryParser 클래스에서 제공하는 분석기와 질의 종류 파악 기능을 그대로 사용

(#2) PhraseQuery만 SpanNearQuery로 변경

(#3) PhraseQuery에 들어있던 텀을 모두 뽑아냄

(#4) SpanNearQuery 인스턴스에 뽑아둔 텀을 모두 설정


6.4 필터 직접 구현

색인 외부의 정보를 활용해 필터를 적용해야 하는 경우 사용

 

ex) 특별 할인 도서 검색 기능 제공 시,

색인 안에 특별 할인 도서 필드를 추가해서 재색인 하는것은 간단하지 않다.

재색인 대신 (가상의) DB 안에 보관ㄷ된 특별 할인 플래그를 읽어 검색 대상을 제한하는 필터를 구현해보자

 

6.4.1 필터 구현

1. 특별 할인 목록 인터페이스 정의

public interface SpecialAccessor {
  String[] isbns();
}

 

2. 특별 할인 도서만 검색 대상으로 제한하는 SpecialaFilter 클래스를 구현

모든 필터는 org.apache.lucene.search.Filter 클래스를 상속받아야 하며, DocIdSet 객체를 리턴하는

getDocIdSet(IndexReaderReader) 메소드를 반드시 구현해야 한다.

 

예제 6.14 SpecialsFilter를 통해 외부 정보를 불러와 필터로 활용

public class SpecialsFilter extends Filter {
  private SpecialsAccessor accessor;

  public SpecialsFilter(SpecialsAccessor accessor) {
    this.accessor = accessor;
  }

  public DocIdSet getDocIdSet(IndexReader reader) throws IOException {
    OpenBitSet bits = new OpenBitSet(reader.maxDoc());

    String[] isbns = accessor.isbns();                  // #1

    int[] docs = new int[1];
    int[] freqs = new int[1];

    for (String isbn : isbns) {
      if (isbn != null) {
        TermDocs termDocs =
          reader.termDocs(new Term("isbn", isbn));      // #2
        int count = termDocs.read(docs, freqs);
        if (count == 1) {                               // #3
          bits.set(docs[0]);                            // #3
        }                                               // #3
      }
    }

    return bits;
  }
}

(#1) 특별할인 대상에 해당하는 ISBN 목록을 불러온다

(#2) IndexReader의 기능을 사용해 각 ISBN에 해당하는 문서를 하나씩 찾아온다.

      ISBN 번호는 유일한 값이기 때문에 ISBN 번호 하나당 하나의 문서만 존재한다.

      Field.Index.NOT_ANALYZED 설정으로 색인했기 때문에 ISBN 번호 자체로 문서를 불러올 수있다.

(#3) ISBN 번호에 해당하는 비트를 openBitSet 객체에 설정한다.

 

6.4.2 직접 구현한 필터 적용

 

특별 할인 목록 제어 메소드

public class TestSpecialsAccessor implements SpecialsAccessor {
  private String[] isbns;

  public TestSpecialsAccessor(String[] isbns) {
    this.isbns = isbns;
  }

  public String[] isbns() {
    return isbns;
  }
}

 

SpecialsFilter 테스트 메소드

  public void testCustomFilter() throws Exception {
    String[] isbns = new String[] {"9780061142666", "9780394756820"};

    SpecialsAccessor accessor = new TestSpecialsAccessor(isbns);
    Filter filter = new SpecialsFilter(accessor);
    TopDocs hits = searcher.search(allBooks, filter, 10);
    assertEquals("the specials", isbns.length, hits.totalHits);
  }

 

6.4.3 필터를 적용하는 다른 방법 : FilteredQuery

FilteredQuery 클래스

- 질의와 필터를 사용해 검색하는 방법을 바꿔준다.

- 일반적인 필터는 검색 질의 조회 시 하나의 필터만 적용하지만, FilteredQuery를 사용하면 어떤 필터든지 모두 질의로 실행할 수 있다.

- 필터를 질의로 변환해 BooleanQuery 안에 하나의 조건으로 추가하는 등의 방법으로 응용할 수 있다.

 

이번엔 교육 분류 중 특별 할인 행사를 하는 책이나, 로고(Logo) 언어에 대한 책을 찾아보자.

 

예제 6.15 FilterQuery 활용

TermQuery와 FilteredQuery를 하나의 BooleanQuery로 묶어 복잡한 질의를 구현한다.

public void testFilteredQuery() throws Exception {
    String[] isbns = new String[] {"9780880105118"};                 // #1

    SpecialsAccessor accessor = new TestSpecialsAccessor(isbns);
    Filter filter = new SpecialsFilter(accessor);

    WildcardQuery educationBooks =                                // #2
      new WildcardQuery(new Term("category", "*education*"));     // #2
    FilteredQuery edBooksOnSpecial =                              // #2
        new FilteredQuery(educationBooks, filter);                // #2

    TermQuery logoBooks =                                         // #3
        new TermQuery(new Term("subject", "logo"));               // #3

    BooleanQuery logoOrEdBooks = new BooleanQuery();                  // #4
    logoOrEdBooks.add(logoBooks, BooleanClause.Occur.SHOULD);         // #4
    logoOrEdBooks.add(edBooksOnSpecial, BooleanClause.Occur.SHOULD);  // #4

    TopDocs hits = searcher.search(logoOrEdBooks, 10);
    System.out.println(logoOrEdBooks.toString());
    assertEquals("Papert and Steiner", 2, hits.totalHits);
  }

(#1) 이 책은 루돌프 스타이너의 『A Modern Art of Education』에 대한 ISBN이다.

(#2) 교육 분류에 속한 책 중 특별 할인 행사를 하는 책만 골라 질의를 생성한다.

(#3) subject 필드에 logo 단어가 들어있는 모든 책을 검색하는 질의를 준비한다.

(#4) 두 개의 질의를 OR 연사자를 이용해 하나로 묶는다.

 

검색 도중 FilteredQuery 질의를 사용할 때마다 그 안에 담겨있는 Filter 객체의 getDocIdSet() 메소드를 호출해 문서 목록을 받아온다. 따라서 필터의 내용이 자주 바뀌지 않으면서 반복적으로 호출되는 경우 필터 결과를 캐시하도록 해야한다.


6.5 적재

루씬의 고급기능인 적재(payload) 기능을 사용하면 색인 과정에서 발견한 모든 텀마다 원하는 바이트 배열 값을 지정할 수 있다. 루씬은 적재된 내용에 관여하지 않는다.

 

텀마다 중요한 내용을 바이트 배열로 저장하여,

검색 시 적재된 값을 불러와 검색결과에 포함 여부 결정

또는 해당 문서의 연관도 점수 계산에 활용

 

6.5.1 분석과 적재

먼저 분석기에서 생성한 토큰마다 중요도를 파악하고 적절한 값을 적재해야 한다.

 

분석기의 TokenStream 객체에서 PayloadAttribute 속성 정의

→ incrementToken 메소드 안에서 Payload 인스턴스를 생성

PayloadAttribute.setPayload 메소드로 적재

 

분석기 관련 Contrib 모듈의 TokenFilter를 통해 Token 객체의 종류나 시작 지점/끝 지점 등의 정보를 적절한 방법으로 변환해 텀에 적재할 수 있다.

 

예제 6.16 특보 문서 안에 담긴 warning 텀의 중요도를 적재하는 BulletinPayloadsFilter 클래스

본문의 맨 앞에 Bulletin이라는 단어가 있으면 문서를 특보라고 판단하고, 특보 문서인 경우 본문에 들어있는 모든 warning 단어마다 중요도를 나타내는 실수 값을 적재한다.

public class BulletinPayloadsFilter extends TokenFilter {

  private TermAttribute termAtt;
  private PayloadAttribute payloadAttr;
  private boolean isBulletin;
  private Payload boostPayload;

  BulletinPayloadsFilter(TokenStream in, float warningBoost) {
    super(in);
    payloadAttr = addAttribute(PayloadAttribute.class);
    termAtt = addAttribute(TermAttribute.class);
    boostPayload = new Payload(PayloadHelper.encodeFloat(warningBoost));
  }

  void setIsBulletin(boolean v) {
    isBulletin = v;
  }

  public final boolean incrementToken() throws IOException {
    if (input.incrementToken()) {
      if (isBulletin && termAtt.term().equals("warning")) {          // 중요도값을
        payloadAttr.setPayload(boostPayload);                        // 적재
      } else {
        payloadAttr.setPayload(null);                                // 적재X
      }
      return true;
    } else {
      return false;
    }
  }
}
public class BulletinPayloadsAnalyzer extends Analyzer {
  private boolean isBulletin;
  private float boost;

  BulletinPayloadsAnalyzer(float boost) {
    this.boost = boost;
  }

  void setIsBulletin(boolean v) {
    isBulletin = v;
  }

  public TokenStream tokenStream(String fieldName, Reader reader) {
    BulletinPayloadsFilter stream = new BulletinPayloadsFilter(
        new StandardAnalyzer(Version.LUCENE_30).tokenStream(fieldName, reader), boost);
    stream.setIsBulletin(isBulletin);
    return stream;
  }
}

 

6.5.2 검색 중 적재된 값 활용

PayloadTermQuery 클래스

- SpanTermQuery와 비슷하게 일치하는 텀을 찾아내며 텀의 위치(스팬)을 모두 보관

- 텀마다 적재된 값을 활용해 연관도 점수를 제어 가능(Similarity 클래스 상속)

public class BoostingSimilarity extends DefaultSimilarity {
  public float scorePayload(int docID, String fieldName, int start, int end, byte[] payload,
      int offset, int length) {
    if (payload != null) {
      return PayloadHelper.decodeFloat(payload, offset);
    } else {
      return 1.0F;
    }
  }
}

PayloadTermQuery 는 찾아낸 모든 텀마다 scorePayload 메소드를 호출해 적재 점수를 계산한 후,
지정한 PayloadFunction 인스턴스를 통해 적재 점수를 합산한다.

PayloadTermQuery(Term term, PayloadFunction function, boolean includeSpanScore)

includeSpanScore 인자 값을 false로 지정하면 SpanNearQuery의 원래 점수를 무시하고 적재 점수를 연관도 점수로 사용한다.

 

예제 6.17 적재된 값으로 특정 텀의 중요도를 추가로 지정

public class PayloadsTest extends TestCase {

  Directory dir;
  IndexWriter writer;
  BulletinPayloadsAnalyzer analyzer;

  protected void setUp() throws Exception {
    super.setUp();
    dir = new RAMDirectory();
    analyzer = new BulletinPayloadsAnalyzer(5.0F);                  // 중요도 값 5.0 지정
    writer = new IndexWriter(dir, analyzer,
                             IndexWriter.MaxFieldLength.UNLIMITED);
  }

  protected void tearDown() throws Exception {
    super.tearDown();
    writer.close();
  }

  void addDoc(String title, String contents) throws IOException {
    Document doc = new Document();
    doc.add(new Field("title",
                      title,
                      Field.Store.YES,
                      Field.Index.NO));
    doc.add(new Field("contents",
                      contents,
                      Field.Store.NO,
                      Field.Index.ANALYZED));
    analyzer.setIsBulletin(contents.startsWith("Bulletin:"));
    writer.addDocument(doc);
  }

  public void testPayloadTermQuery() throws Throwable {
    addDoc("Hurricane warning",
           "Bulletin: A hurricane warning was issued at " +
           "6 AM for the outer great banks");
    addDoc("Warning label maker",
           "The warning label maker is a delightful toy for " +
           "your precocious seven year old's warning needs");
    addDoc("Tornado warning",
           "Bulletin: There is a tornado warning for " +
           "Worcester county until 6 PM today");

    IndexReader r = writer.getReader();
    writer.close();

    IndexSearcher searcher = new IndexSearcher(r);

    searcher.setSimilarity(new BoostingSimilarity());

    Term warning = new Term("contents", "warning");
    
    Query query1 = new TermQuery(warning);
    System.out.println("\nTermQuery results:");
    TopDocs hits = searcher.search(query1, 10);
    TestUtil.dumpHits(searcher, hits);

    assertEquals("Warning label maker",                                // 일반 질의로는 
                 searcher.doc(hits.scoreDocs[0].doc).get("title"));    // 결과 목록 중 첫번째에 위치

    Query query2 = new PayloadTermQuery(warning,
                                        new AveragePayloadFunction());
    System.out.println("\nPayloadTermQuery results:");
    hits = searcher.search(query2, 10);
    TestUtil.dumpHits(searcher, hits);

    assertEquals("Warning label maker",                                // 적재 점수로 중요도를 지정하면
                 searcher.doc(hits.scoreDocs[2].doc).get("title"));    // 결과 목록 중 마지막에 위치
    r.close();
    searcher.close();
  }
}

첫 번째 검색은 일반적인 TermQuery로 진행 - warning 단어를 두개 담고 있는 두번째 문서가 최상위에 위치

두 번째 검색은 PayloadTermQuery로 진행 - 특보인 경우 중요도 5.0을 적재한 warning 텀을 찾아냄

 

실행결과

 

6.5.3 스팬 질의와 적재

SpanQuery의 getSpans 메소드를 통해 받아온 스팬마다 적재된 값이 있다면 불러올 수있다.

getSpans 메소드를 오버라이드해 적재된 값을 기준으로 검색 결과를 걸러내거나,
SpanScorer 클래스를 상속받아 각 스팬에 적재된 값을 기준으로 연관도 점수를 변하는 등의 작업에 사용된다.

 

6.5.4 TermPositions로 적재된 내용 확보

TermPositions

- 특정 텀을 담고 있는 문서를 하나씩 찾아볼 수 있는 반복자(iterator)

- 각 텀에 해당하는 문서와 모든 텀의 위치, 적재된 내용을 받아올 수 있다.

- 적재된 내용은 단 한번만 불러올 수 있다. getPayload 메소드를 호출하면 nextPosition () 메소드를 호출해 다음 문서로 이동하기 전까지 getpayload 메소드를 중복해서 호출해서는 안된다.

//TermPostitions의 메소드
booelan isPayloadAvailable()
int getPayloadLength()
bytep[ getPayload(byte[] data, int offset)

 

728x90
Comments