본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 9. 고급 검색 기법(2) - 스팬질의, 검색필터

by 잭피 2021. 1. 16.

스팬 질의

스팬(span)이란 특정 필드에서 토큰의 시작 위치와 끝 위치를 말합니다

루씬은 SpanQuery 클래스를 기반으로 하는 다양한 종류의 질의를 지원합니다

토큰의 위치 증가 값과 SpanQuery의 하위 클래스를 활용하면 섬세한 형태의 질의로 검색할 수 있습니다

TermQuery는 단순하게 문서를 찾아내지만,

SpanTermQuery 질의는 TermQuery와 같은 문서를 찾아내면서 동시에 질의에 해당하는 텀의 위치까지 모두 파악합니다 (따라서 계산량이 훨씬 많아집니다)

6가지 종류의 SpanQuery를 구현한 클래스

1. SpanTermQuery

다른 종류의 스팬 질의와 함께 사용하는 기본 질의입니다

SpanTermQuery 자체로는 TermQuery와 동일하게 동작하지만, 스팬 질의이기 때문에 각 문서에 담겨있는 검색어의 위치를 모두 담고 있습니다

public void testSpanTermQuery() throws Exception {
	assertOnlyBrownFox(brown)'
	dumpSpans(bronw);
}

 

ex) brown 변수에 SpanTermQuery 인스턴스를 만들어 넣고 dumpSpans 메소드로 스팬 질의의 내용을 화면에 보여줍니다

private void dumpSpans(SpanQuery query) throws IOException {
    Spans spans = query.getSpans(reader);
    System.out.println(query + ":");
    int numSpans = 0;

    TopDocs hits = searcher.search(query, 10);
    float[] scores = new float[2];
    for (ScoreDoc sd : hits.scoreDocs) {
      scores[sd.doc] = sd.score;
    }

    while (spans.next()) {                 // A
      numSpans++;

      int id = spans.doc();
      Document doc = reader.document(id);  // B

      TokenStream stream = analyzer.tokenStream("contents",      // C
                              new StringReader(doc.get("f")));   // C
      TermAttribute term = stream.addAttribute(TermAttribute.class);
      
      StringBuilder buffer = new StringBuilder();
      buffer.append("   ");
      int i = 0;
      while(stream.incrementToken()) {     // D
        if (i == spans.start()) {          // E
          buffer.append("<");              // E
        }                                  // E
        buffer.append(term.term());        // E
        if (i + 1 == spans.end()) {        // E
          buffer.append(">");              // E
        }                                  // E
        buffer.append(" ");
        i++;
      }
      buffer.append("(").append(scores[id]).append(") ");
      System.out.println(buffer);
    }

    if (numSpans == 0) {
      System.out.println("   No spans");
    }
    System.out.println();
  }

  // A Step through each span
  // B Retrieve document
  // C Re-analyze text
  // D Step through all tokens
  // E Print < and > around span
}

그러면 아래와 같은 결과가 나옵니다

f:brown: the quick <brown> fox jumps over lazy dog (0.22097087)

 

그리고 "the" 스팬질의를 dumpSpans 메소드에 넘겨 호출하면 아래와 같은 결과가 나옵니다

 

2. SpanFirstQuery

필드 값의 맨 처음 스팬에 일치하는 문서를 찾아냅니다

public void testSpanFirstQuery() throws Exception {
    SpanFirstQuery sfq = new SpanFirstQuery(brown, 2);
    assertNoMatches(sfq);

    dumpSpans(sfq);

    sfq = new SpanFirstQuery(brown, 3);
    dumpSpans(sfq);
    assertOnlyBrownFox(sfq);
  }

2 범위 안에는 brown 텀이 없어서 첫 번째 질의는 결과를 찾지 못하고 3 범위에서 찾아냅니다

3. SpanNearQuery

다른 스팬과 근처에 위치하는 스팬을 찾아냅니다

즉, 일정 거리 안에 있는 스팬을 찾아주며, 스팬이 지정된 순서대로 위치해야 하는지 아니면 앞뒤가 바뀌어도 괜찮은지 지정할 수 있습니다

public void testSpanNearQuery() throws Exception {
    SpanQuery[] quick_brown_dog =
        new SpanQuery[]{quick, brown, dog};
    SpanNearQuery snq =
      new SpanNearQuery(quick_brown_dog, 0, true);                // #1
    assertNoMatches(snq);
    dumpSpans(snq);

    snq = new SpanNearQuery(quick_brown_dog, 4, true);            // #2
    assertNoMatches(snq);
    dumpSpans(snq);

    snq = new SpanNearQuery(quick_brown_dog, 5, true);            // #3
    assertOnlyBrownFox(snq);
    dumpSpans(snq);

    // interesting - even a sloppy phrase query would require
    // more slop to match
    snq = new SpanNearQuery(new SpanQuery[]{lazy, fox}, 3, false);// #4
    assertOnlyBrownFox(snq);
    dumpSpans(snq);

    PhraseQuery pq = new PhraseQuery();                           // #5
    pq.add(new Term("f", "lazy"));                                // #5
    pq.add(new Term("f", "fox"));                                 // #5
    pq.setSlop(4);                                                // #5
    assertNoMatches(pq);

    pq.setSlop(5);                                                // #6
    assertOnlyBrownFox(pq);                                       // #6
  }

// #1 : 지정한 세 개의 텀을 순서대로 찾아내려면 해당하는 문서가 없습니다
// #2 : 텀을 동일한 순서로 배치하고 슬롭 값을 4로 지정해도 해당하는 문서가 없습니다
// #3 : 슬롭 값이 5가 돼야 문서를 찾을 수 있습니다
// #4 : SpanNearQuery 안에 들어있는 SpanTermQuery의 순서가 
// 원문과 반대이기 때문에 inOrder 인자에 false값을 지정합니다 
// 앞뒤가 바뀐 원문을 찾아낼 때 슬롭 값 3이면 충분합니다
// #5 : PhraseQuery를 준비했지만, 슬롭 값 4로는 문서를 찾을 수 없습니다
// #6 : PhraseQuery 슬롭 값 5를 지정해야 문서를 찾을 수 있습니

4. SpanNotQuery

서로 겹치지 않는 스팬을 찾아냅니다

public void testSpanNotQuery() throws Exception {
    SpanNearQuery quick_fox =
        new SpanNearQuery(new SpanQuery[]{quick, fox}, 1, true);
    assertBothFoxes(quick_fox);
    dumpSpans(quick_fox);

    SpanNotQuery quick_fox_dog = new SpanNotQuery(quick_fox, dog);
    assertBothFoxes(quick_fox_dog);
    dumpSpans(quick_fox_dog);

    SpanNotQuery no_quick_red_fox = new SpanNotQuery(quick_fox, red);
    assertOnlyBrownFox(no_quick_red_fox);
    dumpSpans(no_quick_red_fox);
  }

SpanNotQuery 인스턴스 생성 시, 첫 번째 인자는 결과에 포함혀라는 스팬이고,

두 번째 인자는 결과에서 제외하려는 스팬입니다

 

두 문저 모두 quick 텀과 fox 텀이 범위 안에 있기 때문에 SpanNearQuery 질의의 결과에 포함됩니다

첫 번째 SpanNotQuery인 quick_fox_dog는 quick_fox 스팬과 dog 텀 사이에 겹치지 않습니다

따라서 두 개의 문서 모두 결과에 포함됩니다

 

두 번째 SpanNotQuery인 no_quick_red_fox는 red 텀이 quick_fox 스팬에 겹치기 때문에 결과에서 제외됩니다

 

5. SpanOrQuery

스팬 질의의 결과를 하나로 묶습니다(SpanQuery 배열을 하나로 묶어줍니다 )

public void testSpanOrQuery() throws Exception {
    SpanNearQuery quick_fox =
        new SpanNearQuery(new SpanQuery[]{quick, fox}, 1, true);

    SpanNearQuery lazy_dog =
        new SpanNearQuery(new SpanQuery[]{lazy, dog}, 0, true);

    SpanNearQuery sleepy_cat =
        new SpanNearQuery(new SpanQuery[]{sleepy, cat}, 0, true);

    SpanNearQuery qf_near_ld =
        new SpanNearQuery(
            new SpanQuery[]{quick_fox, lazy_dog}, 3, true);
    assertOnlyBrownFox(qf_near_ld);
    dumpSpans(qf_near_ld);

    SpanNearQuery qf_near_sc =
        new SpanNearQuery(
            new SpanQuery[]{quick_fox, sleepy_cat}, 3, true);
    dumpSpans(qf_near_sc);

    SpanOrQuery or = new SpanOrQuery(
        new SpanQuery[]{qf_near_ld, qf_near_sc});
    assertBothFoxes(or);
    dumpSpans(or);
  }

첫 번째 SpanNearQuery는 quick fox와 lazy dog가 가까이 있는 문서를 찾습니다

두 번째 SpanNearQuery는 quick fox와 sleepy cat 구문이 가까이에 있는 문서를 찾습니다

이 2개의 질의는 quick fox, lazy dog, sleepy cat 구문을 나타내는 SpanNearQuery 인스턴스를 포함합니다

각 SpanNearQuery는 SpanTermQuery로 구성합니다

최종적으로 위 2개의 질의를 SpanOrQuery 안에 묶어 하나의 질의를 완성합니다

 

6. 스팬 질의와 QueryParser

현재 QueryParser에서 SpanQuery 질의를 생성하진 않습니다

하지만 루씬의 contrib 모듈 중 Surround Parser를 사용하면 스팬 질의를 생성할 수 있습니다

PhraseQuery 객체는 충분한 슬롭 값만 지정하면 텀의 순서에 대해서는 전혀 신경 쓰지 않습니다

SpanTermQuery와 SpanNearQuery를 생성하게 구현하면 구문 질의에서 텀의 순서를 지키게 지정할 수도 있습니다

 

검색필터

검색필터는 검색 대상을 줄여줍니다

따라서 색인에 들어있는 문서 중 일부분만을 대상으로 검색합니다

1. TermRangeFilter

특정 필드에 지정한 텀이 있는 문서만 남겨두는 필터이며, 연관도 점수를 계산하지 않습니다

문자열 필드에만 적용할 수 있고, 만약 필드 값이 숫자라면 NumericRangeFilter를 사용해야 합니다

public void testTermRangeFilter() throws Exception {
    Filter filter = new TermRangeFilter("title2", "d", "j", true, true);
    assertEquals(3, TestUtil.hitCount(searcher, allBooks, filter));
  }

마지막에 boolean 인자 2개는 includeLower, includeUpper이며, 범위의 시작 값과 끝 값을 포함할 것인지 여부를 지정합니다

2. NumericRangeFilter

숫자 값이 들어있는 필드의 범위로 필터링합니다

NumericRangeQuery와 동일한 기능이지만, 필터에서는 연관도 점수를 계산하지 않습니다

public void testNumericDateFilter() throws Exception {
    // pub date of Lucene in Action, Second Edition and
    // JUnit in ACtion, Second Edition is May 2010
    Filter filter = NumericRangeFilter.newIntRange("pubmonth",
                                                   201001,
                                                   201006,
                                                   true,
                                                   true);
    assertEquals(2, TestUtil.hitCount(searcher, allBooks, filter));
  }

3. FieldCacheRangeFilter

범위로 검색 대상을 필터링하고자 한다면 FieldCacheRangeFiltere도 좋은 방법입니다

TermRangeFilter, NumericRangeFilter 클래스와 동일한 결과를 얻을 수 있지만, 루씬의 필드 캐시를 사용한다는 점이 다릅니다

필요한 값을 메모리에 이미 담고 있기 때문에 특정 상황에서 훨씬 높은 성능을 발휘합니다

public void testFieldCacheRangeFilter() throws Exception {
    Filter filter = FieldCacheRangeFilter.newStringRange("title2", "d", "j", true, true);
    assertEquals(3, TestUtil.hitCount(searcher, allBooks, filter));

    filter = FieldCacheRangeFilter.newIntRange("pubmonth",
                                               201001,
                                               201006,
                                               true,
                                               true);
    assertEquals(2, TestUtil.hitCount(searcher, allBooks, filter));
  }

4. 특정 텀으로 필터링

2가지 방식이 있습니다

첫 번째는 FieldCacheTermsFilter 필터를 사용하는 방법입니다

내부적으로 루씬의 필드 캐시를 사용합니다

public void testFieldCacheTermsFilter() throws Exception {
    Filter filter = new FieldCacheTermsFilter("category",
                      new String[] {"/health/alternative/chinese",
                                    "/technology/computers/ai",
                                    "/technology/computers/programming"});
    assertEquals("expected 7 hits",
                 7,
                 TestUtil.hitCount(searcher, allBooks, filter));
  }

분류 필드에 배열로 지정한 텀을 갖고 있는 문서는 모두 검색 대상에 포함됩니다

다만 필드의 값으로 단 하나의 텀만 갖고 있어야 합니다

 

두 번째는 루씬 contrib 모듈 중 하나인 TermsFilter 필터입니다

내부적으로 캐시를 사용하지 않지만, 필드 값으로 두 개 이상의 텀을 갖고 있는 경우에도 사용할 수 있습니다

 

5. QueryWrapperFilter

질의를 실행한 결과를 바탕으로 필터를 구성한 다음, 다른 질의에 필터를 적용할 수 있습니다

다시 말해 루씬의 일반적인 모든 질의로 검색한 결과에서 연관도 점수를 제거하고 필터로 변환합니다

public void testQueryWrapperFilter() throws Exception {
    TermQuery categoryQuery =
       new TermQuery(new Term("category", "/philosophy/eastern"));

    Filter categoryFilter = new QueryWrapperFilter(categoryQuery);

    assertEquals("only tao te ching",
                 1,
                 TestUtil.hitCount(searcher, allBooks, categoryFilter));
  }

6. SpanQueryFilter

QueryWrapperFilter와 같은 역할을 담당하지만, 추가로 스팬 질의에서 찾아낸 스팬 관련 정보를 유지한다는 점이 다릅니다

public void testSpanQueryFilter() throws Exception {
    SpanQuery categoryQuery =
       new SpanTermQuery(new Term("category", "/philosophy/eastern"));

    Filter categoryFilter = new SpanQueryFilter(categoryQuery);

    assertEquals("only tao te ching",
                 1,
                 TestUtil.hitCount(searcher, allBooks, categoryFilter));
  }

SpanQueryFilter 필터에는 bitSpans 메소드가 추가로 들어 있으며, bitSpans 메소드를 사용해 결과에 해당하는 각 문서의 스팬 정보를 알아낼 수 있습니다

스팬 정보를 반드시 사용해야 하는 경우가 아니라면 검색 속도 측면에서 QueryWrapperFilter를 사용하는 편이 좋습니다

(루씬에서도 필터를 적용할 때 스팬 정보를 사용하진 않습니다)

 

보안 필터

검색할 때 보안 관련 기능이 필요하다면 필터를 적용하기에 아주 좋습니다

보안 요구 사항이 간단하다면 사용자나 자격 등의 문서와 함께 색인한 후, 검색할 때 QueryWrapperFilter를 사용하는 정도로 충분히 기능을 구현할 수 있습니다

복잡한 보안 요구사항을 구현해야 한다면 외부 정보를 불러오면서 문서를 걸러내는 필터를 사용해야 합니다

이 내용에 대해선 나중에 다시 정리하도록 하겠습니다

 

필터와 BooleanQuery

검색 대상을 제한하는 질의인 MUST 조건으로 BooleanQuery에 추가하여 가져올 수 있지만,

필터를 사용하는 방법과 비교하면 결과는 같지만 내부적으로 큰 차이가 있습니다

CachingWrapperFilter를 사용하면 걸러낸 문서 목록을 캐시할 수 있습니다

따라서 이후에 검색 과정에 해당 필터를 사용할 경우 빠르게 검색할 수 있습니다

 

또한, 정규화된 연관도 점수도 크게 달라집니다

즉, IDF(Inverse document frequency) 값이 크게 달라지기 때문입니다

BooleanQuery를 사용해 결과를 걸러냈다면 해당 질의의 텀을 포함하는 문서가 점수 계산 공식에 반영되만, 필터를 사용하면 대상으로 삼는 문서의 개수가 달라지기 때문에 IDF 값이 바뀝니다

 

BooleanQuery를 사용해 걸러내는 방법은 QueryParser를 사용해 질의를 생성하는 경우 유용합니다

즉, 사용자가 입력한 검색어에서 생성한 질의와 문서를 제한하는 질의를 BooleanQuery로 손쉽게 묶을 수 있기 때문입니다

public void testFilterAlternative() throws Exception {
    TermQuery categoryQuery =
       new TermQuery(new Term("category", "/philosophy/eastern"));

    BooleanQuery constrainedQuery = new BooleanQuery();
    constrainedQuery.add(allBooks, BooleanClause.Occur.MUST);
    constrainedQuery.add(categoryQuery, BooleanClause.Occur.MUST);

    assertEquals("only tao te ching",
                 1,
                 TestUtil.hitCount(searcher, constrainedQuery));
  }

PrefixFilter

PrefixQuery에 해당하는 필터이며, 지정한 접두어로 시작하는 텀에 해당하는 문서만 검색하게 제한합니다

public void testPrefixFilter() throws Exception {
    Filter prefixFilter = new PrefixFilter(
                            new Term("category",
                                     "/technology/computers"));
    assertEquals("only /technology/computers/* books",
                 8,
                 TestUtil.hitCount(searcher,
                                   allBooks,
                                   prefixFilter));
  }

 

필터 캐시

필터를 사용하면서 얻을 수 있는 큰 장점 중 하나는 바로 필터를 캐시하고 재사용할 수 있다는 점입니다 (CachingWrapperFilter는 캐시 관련 기능을 자동으로 처리합니다)

CachingWrapperFilter에는 어떤 종류의 필터이든 모두 캐시할 수 있습니다

 

필터를 재사용하려면 검색할 때 동일한 IndexReader를 사용해야 하기 때문에 필터는 모두 IndexReader를 기준으로 캐시합니다

 

IndexReader 인스턴스를 직접 사용하지 않고 Directory로 직접 IndexSearcher를 생성했다면 역시 동일한 IndexSearcher 인스턴스를 사용해야 캐시된 필터를 사용할 수 있습니다

 

TermRangeFilter를 캐싱하는 예시

public void testCachingWrapper() throws Exception {
    Filter filter = new TermRangeFilter("title2",
                                        "d", "j",
                                        true, true);

    CachingWrapperFilter cachingFilter;
    cachingFilter = new CachingWrapperFilter(filter);
    assertEquals(3,
                 TestUtil.hitCount(searcher,
                                   allBooks,
                                   cachingFilter));
  }

동일한 IndexSearcher 인스턴스와 함께 CachingWrapperFilter 필터를 계속 재사용하면 캐시했던 필터 정보를 그대로 사용하게 됩니다

필터를 질의로 변환

필터를 질의로 변한해 사용할 필요가 있다면 ConstantScoreQuery를 사용하면 됩니다

결과로는 필터에 해당하는 문서만 포함되며, 질의로 지정한 중요도가 결과 문서의 연관도 점수로 지정됩니다

필터에 필터 적용

이미 사용하던 필터가 있는 경우 FilteredDocIdSet 클래스를 상속받아 새로운 클래스를 만들고 원하는 논리 구조를 담은 match 메소드를 구현합니다

그리고 사용하던 필터를 지정하면 원래 필터에 match 메소드로 한 단계 더 필터를 적용할 수 있습니다

FilteredDocIdSet 클래스를 사용하면 검색이나 조회 자격을 강제로 적용할 때 효과적입니다

내장 필터에서 제공하지 않는 기능

루씬의 contrib 모듈로도 여러 종류의 필터가 제공됩니다

예를 들어 ChainedFilter 필터를 사용하면 여러 개의 필터를 원하는 형태로 얼마든지 복잡하게 연결할 수 있습니다

필터만으로 원하는 기능을 충분히 구현할 수 없다면 루씬이 제공하는 FilterdQuery 클래스를 사용할 수 있습니다

이 클래스에 대해선 나중에 다시 알아보도록 하겠습니다

댓글