본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 11. 검색 기능 확장(1) - 정렬, Collector, QueryParser

by 잭피 2021. 1. 26.

정렬 기능 직접 구현

검색 결과를 연관도 점수, 문서 id, 특정 필드의 값 등이 아닌 다른 값으로 정렬하고 싶다면 루씬의 FieldComparatorSource 클래스를 상속받아 정렬 방법을 직접 구현할 수 있습니다

사용자가 GPS 장비를 통해 현재 검색하는 위치를 파악한다고 가정하면 해당 지점의 위치는 검색 시점에만 알 수 있습니다

 

색인 시점에 필요한 준비

지리적인 거리로 검색 결과를 정렬하는 예제를 볼까요?

 

ex) 집(0,0)과 사무실(10,10)에서 가장 가까운 멕시코 음식점은 어디?

먼저 색인하는 코드에는 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);
  }

거리 기준 정렬 기능 구현

먼저 거리 기준 정렬 기능이 올바르게 동작하는지 테스트해보자

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)이고, 테스트 메소드에서 검색 결과 중 가장 가까운 식당과 먼 식당이 정확한지 확인합니다

정렬 기능을 별도로 적용하지 않았다면 색인한 순서대로 결과를 받아옵니다

 

실제 두 지점 간의 거리를 계산해보자

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+")";
  }
}

#2 생성 메소드에는 거리를 비교할 기준 위치의 좌표를 인자로 지정합니다

그리고 setNextReader 메소드가 호출될 때마다 해당 세그먼트의 필드 캐시에서 식당의 좌표를 확보합니다

 

검색할 때 특정 문서가 결과에 포함된다고 판단하면 해당 문서를 큐에 추가합니다

그리고 values 배열에는 큐에 쌓여있는 모든 문서에 대해 계산한 거리를 보관합니다

search 메소드에서 TopDocs 객체를 받아오면 정렬할 때 사용했던 거리를 알 수 없습니다

루씬이 제공하는 저수준 API를 활용해 정렬할 때 계산했던 거리를 검색 결과와 함께 받아봅시다

 

정렬할 때 계산한 값 활용

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

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

TopFieldDocs 클래스는 TopDocs를 상속받은 하위 클래스입니다

TopFieldDocs 객체에는 ScoreDoc 대신 FieldDoc 객체가 배열로 들어있습니다

FieldDoc 객체에는 정렬할 때 계산했던 값과 문서 ID, 그리고 각 SortField에서 사용했던 값과 Comparable 객체가 담겨있습니다

 

ex) 정렬할 때 계산한 값 확보

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 결과 집합에 담겨있는 문서의 개수를 확인합니다

#5 정렬 조건으로 지정된 SortField는 fields 배열에 들어 있습니다

#6 실제 원문인 Document 객체를 받아오기위해 IndexSearcher 메소드를 한 번 더 호출합니다

 

정렬 기능을 직접 구현하면 루씬이 기본적으로 제공하는 연관도 점수 순서 또는 특정 필드 값 순서대로 정렬이 아닌 다른 형태로 정렬할 수 있습니다

Collector 클래스 직접 구현

루씬의 Collector 클래스를 상속받아 별도로 구현한 Collector 클래스를 사용하면 검색 도중 질의에 해당하는 문서를 발견했을 때, 어떻게 처리할 것인지 제어할 수 있습니다

1. Collector 클래스

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

검색 도중 루씬이 검색 조건에 해당하는 문서를 찾아내면 Collector 객체의 collect(int docId) 메소드를 호출합니다

루씬 입장에선 collect 메소드 호출 후, 해당 문서에 대한 관심을 끊고, Collector에게 전적으로 위임합니다

collect 메소드에는 문서 id만 전달하고 연관도 점수는 전달하지 않습니다

만약 필요하다면 Scorer.score() 메소드를 직접 호출해 연관도 점수를 계산해야합니다

연관도 점수는 현재 문서에만 해당하는 휘발성 값이므로 필요하다면 반드시 collect 메소드 안에서 호출해야합니다

2. Collector 직접 구현 : BookLinkCollector

예제) 검색 결과 문서마다 URL과 책 제목을 Map 객체에 담아주는 BookLinkCollector 클래스

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;                            // #A
  }

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

  public void setNextReader(IndexReader reader, int docBase) throws IOException {
    urls = FieldCache.DEFAULT.getStrings(reader, "url");           // #B
    titles = FieldCache.DEFAULT.getStrings(reader, "title2");      // #B
  }

  public void collect(int docID) {
    try {
      String url = urls[docID];            // #C
      String title = titles[docID];        // #C
      documents.put(url, title);           // #C
      System.out.println(title + ":" + scorer.score());
    } catch (IOException e) {
      // ignore
    }
  }

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

// #A : 문서 ID의 순서에 상관없음
// #B : 필드 캐시를 불러옴
// #C : 검색 결과 문서에 해당하는 URL과 제목을 확보함

검색 결과 문서 ID를 보관하지 않습니다 (일반적인 루씬의 Collector와 많이 다릅니다)

url과 titles 배열은 세그먼트별 필드 캐시를 활용합니다

결국 문서 id를 사용하지 않으므로 setNextReader 메소드를 통해 넘겨받은 docBase 값은 무시합니다

 

직접 구현한 Collector 클래스를 사용하려면 별도의 search 메소드를 사용해야 합니다

public class CollectorTest extends TestCase {

  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();
  }
}

검색 도중 루씬이 질의에 해당하는 문서를 찾아내면 docID를 BookLinkCollector에게 넘겨줍니다

3. AllDocCollector

검색 결과 건수가 아주 많지 않으면 모든 문서를 모두 기록하고자 할 수 있습니다

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,         // #A
                   scorer.score()));    // #B
  }

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

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

AllDocCollector의 acceptDocsOutOfOrder() 메소드는 true이므로 gitHits() 메소드로 받아온 목록의 순서는 뒤섞여 있을 가능도 있습니다

만약 순서를 지키고 싶다면 acceptDocsOutOfOrder()가 false를 리턴하도록 변경해야합니다

 

이렇게 Collector 클래스는 손쉽게 직접 구현할 수 있습니다

루씬은 검색 도중 찾아낸 문서의 id를 알려줄 뿐이고, 나머지 작업은 Collector 안에서 해결합니다

 

QueryParser 확장

QueryParser를 상속받아 질의를 생성하는 기능 자체를 변경할 수 있습니다

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

예제) CustomQueryParser 클래스는 QueryParser를 상속받아 퍼지 질의와 와일드카드 질의를 사용하지 못하게 제한합니다

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");
  }

CustomQueryParser를 사용하려면 CustomQueryParser 인스턴스를 생성한 후 원래 QueryParser를 사용하던 위치에 그대로 CustomQueryParser를 대치해 사용합니다

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) {
                                         // 1
    }

    try {
      parser.parse("xunit~");
      fail("Fuzzy queries should not be allowed");
    } catch (ParseException expected) {
                                         // 1
    }
  }

퍼지나 와일드카드 등 성능에 큰 영향을 미치는 질의를 사용하지 못하게 막을 수 있습니다

숫자 범위 질의 처리

QueryParser는 NumericRangeQuery 등의 질의를 생성하는 기능이 없습니다

QueryParser를 상속받아 약간의 코드를 추가하면 숫자 범위에 대해 NumericRangeQuery를 생성하게 확장할 수 있습니다

 

예제) 숫자 필드를 올바로 다루게 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)            // A
        super.getRangeQuery(field, part1, part2,         // A
                              inclusive);                // A
      if ("price".equals(field)) {
        return NumericRangeQuery.newDoubleRange(         // B
                      "price",                           // B
                      Double.parseDouble(                // B
                           query.getLowerTerm()),        // B
                      Double.parseDouble(                // B
                           query.getUpperTerm()),        // B
                      query.includesLower(),             // B
                      query.includesUpper());            // B
      } else {
        return query;                                    // C
      }
    }
  }

  /*
    #A 상위 클래스에서 기본적으로 생성하는 TermRangeQuery 확보
    #B 검색어에 해당하는 NumericRangeQuery 인스턴스를 생성해 리턴
    #C 원래 생성했던 TermRangeQuery 리턴
  */

날짜 범위 질의 처리

예제) 날짜 필드를 처리하게 확장한 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;
      }
    }
  }

순서가 정해진 구문 질의

분석기로 분석한 결과 텀의 개수가 2개 이상이라면 getFieldQuery 메소드의 결과로 PhraseQuery 객체를, 텀이 하나라면 TermQuery 객체를 리턴합니다

 

PhraseQuery 에 지정된 슬롭 값이 적절한 경우 원문에서 텀의 순서가 뒤바뀐 문서도 결과로 찾아내므로 텀의 순서를 강제할 방법이 없습니다

 

반면 SpanNearQuery 질의는 텀의 순서를 그대로 지키는 문서만 찾아낼 수 있습니다

따라서 SpanNearQuery로 변경합니다

 

예제) 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 Delegate to QueryParser's implementation
#2 Only override PhraseQuery
#3 Pull all terms
#4 Create SpanNearQuery
  */

#1 : QueryParser 클래스에서 제공하는 분석기와 질의 종류 파악 기능을 그대로 활용합니다

#2 : PhraseQuery가 아닌 질의는 그대로 리턴하고, PhraseQuery만 SpanNearQuery로 변경합니다

#3 : 앞서 생성된 PhraseQuery에 들어있던 텀을 모두 뽑아냅니다

#4 : SpanNearQuery 인스턴스를 생성한 후 PhraseQuery에서 뽑아둔 텀을 모두 설정합니다

댓글