본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 5. 검색(2) - Score & Query

by 잭피 2020. 12. 1.

이번에는 연관도 점수와 다양한 종류의 질의에 대해서 살펴보겠습니다

연관도 점수

점수는 문서와 질의가 얼마나 비슷한지를 숫자로 표현한 값입니다

점수가 높을수록 연관도가 높고, 더 확실한 결과라고 볼 수 있습니다

점수 계산

유사도 점수 계산 공식(similarity scoring formula) : 각 문서가 질의와 얼마나 가까운지를 측정

(유사도 점수는 질의(q), 텀(t), 문서(d)를 기준으로 계산)

 

 

 

루씬에서 질의와 문서의 유사도를 계산하는 공식

 

 

루씬은 지금까지 알려진 최상의 유사도 계산 공식을 사용하고 있지만, 루씬의 검색 기능을 사용할 때 점수 계산 공식의 세부적인 내용까지 알아야만 하는 건 아니다 (부담을 느낀다면 건너뛰자)

유사도 점수 계산 공식으로 산출한 값은 원본 점수입니다

0.0보다 크거나 같은 실수로 표현합니다

 

일반적으로 특정 문서의 유사도 점수를 보여줄 때는

검색 결과에 포함된 각 문서의 연관도 점수를 결과 전체의 최고 점수로 나눠 정규화한 값을 보여주는 편이 낫습니다

(문서의 유사도 점수가 높을수록 해당 문서가 질의와 비슷하다고 판단)

 

따라서 루씬은 유사도 점수 기준 내림차순으로 정렬하여, 가장 유사도가 높은 문서가 가장 앞에 위치합니다

 

질의나 필드에 지정된 중요도 역시 유사도 점수에 영향을 줍니다 그래서 공식에 포함되어 있습니다

필드에 대한 중요도는 색인할 때 지정하며, 공식 안에는 boost() 항목에 들어갑니다

필드별 중요도 기본 값은 1.0입니다

색인 과정에서 문서에도 중요도를 지정할 수 있습니다

각 필드의 중요도는 (해당 문서의 중요도 X 필드의 중요도)입니다

 

질의 자체도 문서의 유사도 점수에 영향을 줍니다

Query 인스턴스에 중요도를 지정할 수도 있지만,

이런 방법은 다수의 Query를 묶어 사용하는 경우에 중요도를 지정할 수 있습니다

다중 질의로 검색할 때는 비슷한 문서라 해도 중요도가 높은 질의에 해당하는 문서가 중요도가 낮은 질의에 해당하는 문서보다 유사도 점수를 높게 받습니다 (아디다스 패딩?)

모든 질의의 중요도 기본 값은 1.0입니다

 

유사도 점수 계산 공식의 대부분의 항목은 Similarity 클래스의 하위 클래스를 구현해 마음대로 조절할 수 있습니다

(기본 값 : DefaultSimilarity 클래스)

DefaultSimilarity 클래스 내부에서도 상당한 양의 계산 작업이 이루어집니다

예를 들어 텀 빈도수 항목은 실제 빈도수의 제곱근을 사용합니다

점수 계산 방법이나 공식을 변경하고자 한다면 Similarity 클래스의 자바독 API 문서를 살펴봅시다

 

explain() 메소드로 점수 내역 확인

유사도 점수 계산 공식이 어떻게 적용되었는지 루씬이 제공하는 설명 기능이 있습니다

IndexSearcher 클래스의 explain() 메소드입니다

Query 객체와 문서 ID 값을 넘겨주면 Explanation 객체를 리턴해줍니다

Explanation 객체는 내부적으로 점수 계산에 관여했던 모든 항목에 대한 상세한 내역을 담고 있습니다

// explain() 메소드 사용 사례
public class Explainer {
  public static void main(String[] args) throws Exception {
    if (args.length != 2) {
      System.err.println("Usage: Explainer <index dir> <query>");
      System.exit(1);
    }

    String indexDir = args[0];
    String queryExpression = args[1];

    Directory directory = FSDirectory.open(new File(indexDir));
    QueryParser parser = new QueryParser(Version.LUCENE_30,
                                         "contents", new SimpleAnalyzer());
    Query query = parser.parse(queryExpression);

    System.out.println("Query: " + queryExpression);

    IndexSearcher searcher = new IndexSearcher(directory);
    TopDocs topDocs = searcher.search(query, 10);

    for (ScoreDoc match : topDocs.scoreDocs) {
      Explanation explanation
         = searcher.explain(query, match.doc);     //#A

      System.out.println("----------");
      Document doc = searcher.doc(match.doc);
      System.out.println(doc.get("title"));
      System.out.println(explanation.toString());  //#B
    }
    searcher.close();
    directory.close();
  }
}

Explanation 객체를 사용하면 유사도 점수 계산 내역을 손쉽게 확인할 수 있지만,

상세 내역을 뽑아낼 때 질의를 처리하는 것만큼 시스템 자원을 소모합니다

 

따라서 꼭 필요한 부분에서만 Explanation 객체를 사용하는 편이 좋습니다

 

다양한 종류의 질의

루씬에 내장된 Query 종류를 살펴보고,

TermQuery, TermRangeQuery, NumericRangeQuery

PrefixQuery, BooleanQuery, PhraseQuery, WildcardQuery, FuzzyQuery

그리고 특이한 질의 중 하나인 MatchAllDocsQuery까지 알아봅시다

 

TermQuery 텀 검색

텀은 색인에 들어있는 작은 단위를 뜻하며, 필드의 이름과 텍스트 형태의 값으로 구성됩니다

// name이란 필드에 jack이란 값
Term t = new Term("name", "jack");
// 위에서 만든 Term 인스턴스를 사용해 TermQuery를 생성합니다
Query qeury = new TermQuery(t);
// -> name 필드 안에 jack이라는 단어를 가진 문서를 모두 검색결과를 받습니다

TermQuery는 식별자 등으로 원하는 문서를 찾아와야 할 때 유용합니다

 

TermRangeQuery 텀 범위 검색

Term은 색인 안에서 알파벳 순서로 정렬된 상태로 (String.compareTo 메소드 기준) 나열돼 있습니다

따라서 루씬의 TermRangeQuery로 원하는 범위의 텀을 손쉽게 뽑아낼 수 있습니다

예를 들어 N부터 Q까지의 알파멧으로 시작하는 이름을 찾는 등의 용도에만 사용합시다

반면 숫자 범위 등의 검색은 NumericRangeQuery 클래스를 사용합시다

// 제목의 첫 글자가 d부터 j까지의 알파벳에 해당하는 모든 책을 찾아줍니다
TermRangeQuery query = new TermRangeQuery("title", "d", "j", true, true);

메소드의 뒤에 들어가는 파라미터 불리언 값 두 개는 각각 시작점과 종료점의 범위를 포함할 것인지에 대한 여부입니다

 

루씬은 색인에 텀을 저장할 때 항상 사전 순서로 정렬해 저장하며,

따라서 범위로 지정한 텀 역시 동일한 순서로 지정해야합니다

필요한 경우 TermRangeQuery 인스턴스에 Collator를 직접 설정해 범위를 확인할 때 사용할 수 있습니다

 

NumericRangeQuery 숫자 범위 검색

색인할 때 NumericField를 사용했다면, NumericRangeQuery를 사용해 효율적으로 원하는 범위의 숫자를 검색할 수 있습니다

루씬 내부적으로 숫자가 색인된 트라이 구조와 같은 형태로 동일한 공간에 맞춰 질의의 범위를 변환합니다

각 공간은 색인에서 각자를 나타내는 텀으로 색인된 상태이며, 범위에 해당하는 공간을 가리키는 텀의 문서가 결과에 모두 포함됩니다

따라서 단순한 텀에 비해 트라이 구조의 공간 개수가 상대적으로 적기 때문에

TermRangeQuery보다 NumericRangeQuery의 속도가 훨씬 빠릅니다

NumericRangeQuery qeury = NumericRangeQuery.newIntRange("pubmonth", 200605, 200609, true, true)

두 개의 불리언 값 인자는 시작 값과 종료 값을 포함할 것인지 아니면 제외할 것인지 여부입니다

 

PrefixQuery 접두어 검색

PrefixQuery는 지정한 문자열로 시작하는 모든 텀을 갖고 있는 문서를 찾아줍니다

예를 들어 /technology/computers/programming 하위 계층의 도서 정보를 PrefixQuery를 통해 찾을 수 있습니다

// /technology/computers/programming 하위 계층을 포함해 검색합니다
Term term = new Term("category", "/technology/computers/programming"); 
PrefixQuery query = new PrefixQuery(term);
TopDocs matchs = searcher.search(query, 10);

BooleanQuery 불리언 질의

다양한 종류의 질의는 모두 BooleanQuery를 통해 복잡한 형태로 묶어 사용할 수 있습니다

BooleanQuery 안에 들어가는 각 질의는 절(clause)이라고 부릅니다

AND, OR, NOT 등의 논리 구조를 구현할 수 있습니다

절을 추가하려면 아래의 메소드를 통해 추가할 수 있습니다

public void add(Query query, BooleanClause.Occur occur)

occur의 경우,

BooleanClause.Occur.Must,

BooleanClause.Occur.SHOULD,

BooleanClause.Occur.MUST_NOT 중 하나를 지정합니다

그리고 다른 BooleanQuery의 절을 추가해 원하는 형태로 중첩해 사용할 수 있습니다

 

예제) subject 필드에 search 단어를 포함하고, 그와 동시에 출판 시점이 2010년인 도서를 찾음

TermQuery searchingBooks = new TermQuery(new Term("subject", "search"));
Query books2010 = NumericRangeQuery.newIntRange("pubmonth", 201001, 201012, true, true);

BooleanQuery searchingBooks2010 = new BooleanQuery();
searchingBooks2010.add(searchingBooks, BooleanClause.Occur.MUST);
searchingBooks2010.add(books2010, BooleanClause.Occur.MUST);

IndexSearcher searcher = new IndexSearcher(dir);
TopDocs matches = search.search(searchingBooks2010,10);

BooleanClause.Occur.SHOULD를 이용하면 OR 검색 결과를 이용할 수 있습니다

 

BooleanQuery에 추가할 수 있는 절의 개수는 기본 값으로 1,024개까지 추가할 수 있습니다

절의 개수가 너무 많아져 검색 성능이 떨어지는 현상을 막고자 루씬에서는 개수를 제한하고 있습니다

(만약 제한된 개수가 넘으면 TooManyClauses 예외가 발생합니다)

PhraseQuery 구문 검색

PhraseQuery 질의는 위치 정보(omitTermFreqAndPositions 설정)를 활용해 원하는 텀이 일정 거리 안에 존재하는 문서를 찾아줍니다

예를 들어 "the quick brown fox jumped over the lazy dog" 문장이 있다

"quick"과 "fox"가 붙어 있거나 그 사이에 최대 하나의 다른 단어를 포함하는 문서를 찾으려고 한다고 가정해보자

(가깝다는 기준으로 지정하는 텀 사이의 최대 거리를 slop(슬롭)이라고 부릅니다)

(distance(거리)는 구문을 원래 순서대로 맞추려 할 때 움직여야 하는 이동 거리를 뜻합니다)

Document doc = new Document();
doc.add(new Field("field", "the quick brown fox jumped over the lazy dog",
							Field.Store.YES,
							Field.Store.ANALYZED));

private boolean matched(String[] phrase, int slop) {

	// PhraseQuery 인스턴스 생성
	PhraseQuery query = new PhraseQuery();
	query.setSlop(slop);

	// 지정된 단어 순서대로 구문으로 추가
	for (String word : phrase) {
		query.add(new Term("field", word));
	}

	TopDocs matches = searcher.search(query,10);
	return matches.totalHits > 0;
}

PhraseQuery의 기본 슬롭 값은 0입니다 (구문 질의에 지정한 단어가 순서대로 연달아 나오는 문서만 검색)

String[] phrase = new String[] {"quick", "fox"};
assertFalse("exact phrase not found", matched(phrase, 0));
assertTrue("close enough", matched(phrase, 1));

 

순서가 슬롭 값에 영향을 받긴 하지만, 구문 질의에 추가한 단어가 필드에 존재하는 단어의 순서와 일치해야만 검색 결과에 포함되는 것은 아닙니다

 

예를 들어 질의에 fox를 먼저 넣고 다음에 quick을 넣는 순서로 넣는다면 한 번이 아니라 세 번을 움직여야 일치합니다

 

즉, 앞에 있는 fox 단어를 2칸을 움직여야 quick 뒤로 가고, 1칸을 더 움직여야 하나의 공간을 여유로 둘 수 있습니다

 

fox 단어를 한 번 움직이면 quick과 같은 위치에 오고, 한 번 더 움직이면 quick의 바로 뒤에 오고, 한 번 더 움직여야 quick 다음에 하나의 여유 공간 이후로 이동해 'quick brown fox' 등의 구문을 찾아낼 수 있습니다

 

다수 텀 구문

PhraseQuery 클래스에는 여러 개의 텀을 지정할 수 있습니다

 

구문 질의 점수 계산

필요한 이동 거리를 기반으로 점수를 계산합니다

이동 거리가 가까운 문서가 더 높은 점수를 받습니다

구문 검색 점수 계산 공식 : 1/이동거리 

 

WildcardQuery 와일드카드 검색

와일드카드 질의를 사용하면 단어를 완벽하게 모르는 상태에서도 검색 결과를 받아볼 수 있습니다

사용법은 표준적인 와일드카드 그대로 사용합니다

 

* 와일드카드 : 글자가 없거나 하나 이상의 여러 글자에 대응

? 와일드카드 : 글자가 없거나 아니면 글자 하나에 해당

 

Query query = new WildcardQuery(new Term("contents", "?ild*"));

내부적으로 일반적인 텀 검색처럼 동작하지 않습니다

하지만 Term 객체로 지정한다는 점을 기억하자

루씬은 와일드카드 질의에 설정된 텀을 색인의 단어를 검색하는 패턴으로 사용합니다

즉, Term 클래스를 필드 이름과 필요한 문자열을 값으로 저장하는 공간으로만 사용합니다

 

주의) WildcardQuery를 사용하면 검색 성능이 떨어지는 경우가 많습니다
와일드카드가 아닌 접두어가 길면 길수록 검색 과정에서 조회해야 할 텀의 개수가 적어집니다
반대로 단어의 접두어로 와일드카드를 사용하면 색인의 모든 텀을 조회해야 하기 때문에 성능이 떨어집니다

 

또한, 와일드카드 패턴에 단어가 얼마나 가까운지는 점수에 영향이 없습니다

 

FuzzyQuery 비슷한 단어 검색

FuzzyQuery를 사용하면 질의에 지정한 단어와 비슷한 단어를 찾아줍니다

레벤스타인 거리(편집거리) 알고리즘을 사용해 색인의 단어와 질의의 단어 사이의 거리르 계산하고 비슷한 단어인지 확인합니다

삭제, 추가, 대치 등의 작업을 적용해 같은 문자열로 변환하는 데 필요한 작업의 수입니다

예를 들어 three와 tree일 때, h 한글자를 삭제하면 같아지므로 편집 거리는 1입니다

 

편집 거리와 PhraseQuery 또는 PrefixQuery에서 사용하는 거리 계산 방법은 서로 다르니 주의하자
구문 질의에서 사용하는 거리는 텀을 이동하는 횟수를 말합니다

 

편집 거리가 짧은 문서가 더 높은 점수를 받습니다

또한, 역문서 빈도수 (IDF, Inverse Document Frequency) 등의 다른 수치도 영향을 받습니다

 

FuzzyQuery는 색인된 모든 텀을 대상으로 편집 거리를 계산합니다
따라서 꼭 필요한 경우에만 사용해야 하며, 최소한 FuzzyQuery 내부에서 어떤 작업이 일어나고 성능이 얼마나 떨어질 수 있는지에 대해 알고 있어야 합니다

 

MatchAllDocsQuery 모든 문서 조회

색인에 들어있는 모든 문서를 검색 결과로 가져옵니다

기본 설정으로 모든 결과 문서에 각 문서의 중요도 값을 유사도 점수로 지정합니다

 

 

QueryParser로 질의 표현식 파싱

QueryParser를 사용하면 텍스트 형태의 질의 표현식을 기반으로 간단히 Query 객체를 생성할 수 있습니다

 

QueryParser에서 검색어로 특수 문자를 사용하고 싶으면 역슬래시(\) 기호를 사용해 이스케이프해야 합니다
ex) \ + - ! ( ) : ^ ] { } ~ * ?

 

Query.toString

Query 클래스의 toString() 메소드는 질의 표현식에서 의도했던 Query 객체가 올바르게 생성됐는지 확인하는 방법 중 하나입니다

기본적으로 Query를 상속받아 구현한 모든 클래스는 toString() 메소드를 구현하고 있습니다

복잡한 질의 객체에 문제가 있어 디버깅할 때 유용합니다

사용자가 입력한 표현식을 QueryParser가 어떻게 변환하는지 알아보고자 할 때 사용하면 유용합니다

 

TermQuery

QueryParser는 검색어 표현식의 단일 단어를 TermQuery로 변환합니다

public void testTermQuery() throws Exception {
	QueryParser parser = new QueryParser(Version.LUCENE_30, "subject", analyzer);
	Query query = parser.parse("computers");
	System.out.println("term : " + query); 
	// 결과 : term : subject:computers 
}

QueryParser를 생성할 때 지정했던 기본 필드 이름인 subject를 질의 표현식인 computers에 적용했습니다

 

텀 범위 검색

문자열이나 날짜 등의 범위 검색은 괄호를 사용합니다

시작 텀과 종료 텀 사이에는 TO 구문을 지정합니다 (TO는 모두 대문자로 작성)

시작과 종료 값을 모두 포함하거나, 아니면 모두 제외해야 합니다 (한 곳만 있으면 안됩니다)

// 양쪽 끝을 포함하는 범위
Query query = new QueryParser(Version.LUCENE_30, "subject", analyzer)
									.parse("title2:[Q TO V]");

assertTrue(TestUtil.hitsIncludeTile(searcher, matches, "Tapestry in Action"));

// 양쪽 끝을 제외하는 범위
Query query = new QueryParser(Version.LUCENE_30, "subject", analyzer)
									.parse("title2:[Q TO \"Tapestry in Action\]");

assertFalse(TestUtil.hitsIncludeTile(searcher, matches, "Tapestry in Action"));

 

QueryParser.setLowercaseExpandedTerms(false)를 호출하지 않으면 텀 범위 검색의 텀은 모두 소문자로 바뀝니다
범위 검색 안에 들어있는 문자열은 분석기를 거치지 않습니다
시작 또는 종료 텀이 두 단어 이상으로 이뤄져 공백이 있는 경우는 반드시 큰 따옴표로 묶어줘야 합니다

 

숫자와 날짜 범위 검색

현재 QeuryParser 문법으로는 NumericRangeQuery를 생성하지 않습니다

루씬에서는 필드마다 NumericField로 색인했는지의 여부를 기억하고 있지 않습니다

 

접두어 질의와 와일드 카드 질의

텀에 별표(*)나 물음표(?)가 포함되어 있다면 해당 텀은 WildcardQuery로 변환됩니다

(별표(*)가 단어의 뒤에만 있다면 WildcardQuery 대신 PrefixQuery로 변환합니다)

모두 텀을 소문자로 변경합니다 (소문자로 변경하지 않게 설정이 가능합니다)

QueryParser의 기본설정으로 단어의 맨 앞에 와일드카드를 사용하지 못하게 제한되어있습니다

(성능이 급격히 떨어지기 때문입니다)

만약 성능을 포기하고 사용하고 싶다면 setAllowLeadingWildcard 메소드를 통해 설정할 수 있습니다

불리언 연산자

QueryParser에서 불리언 질의를 생성하려면 AND, OR, NOT의 불리언 연산자를 사용합니다 (대문자)

OR이 기본 연산자입니다

만약 기본 연산자를 바꾸고 싶다면 아래처럼 바꿀 수 있습니다

QueryParser parser = new QueryParser(Version.LUCENE_30,"contents", analyzer);
parser.setDefaultOperator(QueryParser.AND_OPERATOR);

만약 텀 앞에 NOT 연산자를 지정하면 해당 텀을 포함하는 문서를 제외합니다

ex) 'NOT term' : term 단어를 포함하지 않는 모든 문서를 검색합니다

 

불리언 연산자 기호

[문자열 형식] - [기호 형식]

[a AND b] - [+a +b]

[a OR b] - [a b]

[a AND NOT b] - [+a -b]

구문 질의

여러 텀이 큰따옴표에 안에 들어 있다면 PhraseQuery로 변환합니다

"This is Some Phrase*" 라는 질의 표현식이 있고 StandardAnalyzer로 분석했다면 결과적으로 'some phrase'라는 구문 질의가 생성됩니다 (this, is 불용어 제거)

앞에 예제 질의 표현식에서 *가 왜 와일드카드로 처리되지 않을까요?

별표 기호보다 큰따옴표의 우선순위가 높으며, 큰따옴표로 묶여있는 문자열은 모두 구문 질의로 처리하기 때문입니다

퍼지 검색

물결 기호(~)를 텀 뒤에 붙이면 해당 검색어로 퍼지 질의가 생성됩니다

퍼지 검색의 물결 기호 뒤에는 추가적으로 별도의 최소 유사도 임계값을 실수 형태로 지정할 수 있습니다

parser.parse("kountry~0.7");

MatchAllDocsQuery

질의 표현식으로 ":"문자열을 입력하면 QueryParser에서 MatchAllDocsQuery 질의를 생성합니다

질의 그룹

루씬의 불리언 질의를 사용하면 복잡하게 중첩된 질의를 만들어 낼 수 있습니다

QueryParser에서도 그룹(Grouping) 문법을 통해 표현한 복잡한 질의 표현식을 해석해 동일한 Query를 생성합니다

필드 선택

검색어마다 필드 이름을 지정하는 문법을 사용하면 검색어별로 검색하고자 하는 대상 필드의 이름을 지정할 수 있습니다

하위 질의에 중요도 지정

캐럿(^) 문자 다음에 실수를 지정하면 해당 검색어의 중요도를 지정할 수 있습니다

ex) junit^.20 testing

 

 

 

 

 

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

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

 

 

 

댓글