본문으로 바로가기
반응형

이번에는 Query의 종류에 대해서 설명합니다.

검색을 하다보면 단순히 "people" 같은 문구 한개를 찾기 보다는, 여러 조건을 조합해서 질의를 만들어야 할때가 있습니다.

따라서 lucene에서는 기본적으로 사용하는 Term query 이외에도 다른 query들을 지원합니다.

이런 쿼리들은 org.apache.lucene.search.query 패키지 안에 존재합니다.


lucene api는 8.2.0을 기준으로 합니다.

또한 예제는 Kotlin으로 작성되었습니다.


Term query

앞의 예제에서 계속 사용한 query 입니다. 검색 할 Term을 설정하여 해당 Filed에서 요청한 문자를 찾습니다.
이건 앞쪽에서 계속 사용해 왔으니 예제는 생략합니다.
검색할 문자에 띄어쓰기가 들어있다면 TermQuery를 직접 생성하기 보다는 QueryParser를 이용하여 query를 생성하는게 좋습니다.

Boolean query

두개 이상의 쿼리를 조합하여 사용합니다.
ex) TermsQuery, PhaseQuery 또는 BooleanQuery를 조합할 수 있습니다.
BooleanQuery는 builder를 이용하여 작성합니다

fun getBooleanQuery(): BooleanQuery {
        val query1 = TermQuery(Term("content", "lucene"))
        val query2 = TermQuery(Term("content", "query"))
        val booleanQuery = BooleanQuery.Builder()
                .add(query1, BooleanClause.Occur.MUST)
                .add(query2, BooleanClause.Occur.MUST_NOT)
        return booleanQuery.build()
    }


BooleanClause.Occur

 MUST

 반드시 포함해야 하는 term

 MUST_NOT

 반드시 포함하지 않아야 하는 term

 FILTER

 MUST 처럼 반드시 포함해야 하지만 score에는 포함시키지 않음.

 SHOULD

 OR의 의미임.

 MUST가 없는 BooleanQuery에서 match되려면 document가  반드시 하나 이상의 SHOULD query와 match 되어야함.


너무 많은 BooleanCluase를 추가하면 TooManyClauses Exception이 발생하며, 기본값은 1024개 입니다.

이를 변경하려면 BooleanQuery.setMaxClauseCount(int)를 수정하면 됩니다.


Wildcard query

WildcardQuery는 문자열의 부분 검색을 가능하게 합니다.
SQL로 따지면 like 검색에서 '%' 나 '_'의 역할 입니다.
다만, wildcard를 사용하면 전체 term을 iteration 하여 검색하므로 속도가 느립니다.
검색속도를 극도로 느리게 하지 않기 위해서는 첫글자에 '*'를 사용하지 않도록 해야 합니다.

fun getWildcardQuery(): WildcardQuery {
        return WildcardQuery(Term("content", "app*"))
}

위와 같이 검색하면 apple, application, apply...등등의 단어가 전부 검색됩니다.


Wildcard type

 * 

character sequence를 대체 

 ?

single character를 대체 

 /

escape character 


Prefix query

PrefixQuery는 말 그대로 해당 문구로 시작하는 term을 모두 찾습니다.
fun getPrefixQuery(): PrefixQuery {
    return PrefixQuery(Term("content", "app"))
}

는 WildcardQuery에서 "app*"로 검색한것과 동일한 검색 결과를 반환합니다.


Phrase query

PharseQuery는 특정 구문(단어의 조합)을 찾을때 사용합니다.
예를 들면 ("Hong gil dong") 이란 단어를 찾으려면 Phrae query를 사용해야 합니다.

아래 예제는 document에서 "indexing and search" 중 indexingsearch만 검색하기 위한 구문 입니다.
fun getPhraseQuery1(): PhraseQuery {
    val query1 = Term("content", "indexing")
    val query2 = Term("content", "search")
    return PhraseQuery.Builder().setSlop(2).add(query1).add(query2).build()
}

fun getPhraseQuery2(): PhraseQuery {
    val query1 = Term("content", "indexing")
    val query2 = Term("content", "search")
    return PhraseQuery.Builder().add(query1, 0).add(query2, 2).build()
}

fun getPhraseQuery3(): PhraseQuery {
    val query1 = Term("content", "indexing")
    val query2 = Term("content", "search")
    return PhraseQuery.Builder().add(query1, 4).add(query2, 6).build()
}

getPhraseQuery1()은 setSlop을 설정합니다. indexing과 search의 거리는 2이므로 setSlop(2)를 설정시 해당 문구가 검색됩니다.

물론 setSlop()의 값을 2 이상의 다른 값을 설정해도 당연히 검색 됩니다.


두번째로 getPhraseQuery2()은 builder를 통해 term을 입력할대 실제 두 단어간의 position을 직접 입력합니다. 상대적인 값이므로 position이 2개만 떨어져 있다는걸 표현하면 됩니다.

따라서 getPhraseQuery3() 역시 상대적으로 2개의 거리가 떨어져 있음을 나타내므로 원하는 대로 검색됩니다.


Multi Phrase Query

MultiPhraseQuery는 PhraseQuery의 일반화된 버전으로 동일한 위치에 한개 이상의 term을 or로 취급할 수 있습니다. 
말로 설명하기는 어렵습니다. 예제로 이해하려면 예제문장이 더 필요하겠네요.
감자를 더 맛있게 먹는 방법은 밥솥에 밥과 함께 지는것이다.

감자를 먹는 방법중에 간장에 졸여서 반찬으로 먹는 방법이 있다.

감자와 고구마는 구황작물이었다.

세개의 문장을 모두 검색하고 싶다면 아래와 같이 query를 만들 수 있습니다.

fun getMultiPhraseQuery(): MultiPhraseQuery {
            val startTerm = arrayOf(
                    Term("content", "감자를"),
                    Term("content", "감자와")
            )

            val endTerms = arrayOf(
                    Term("content", "밥솥에"),
                    Term("content", "반찬으로"),
                    Term("content", "구황작물")
            )

            return MultiPhraseQuery.Builder()
                    .add(startTerm)
                    .add(endTerms)
                    .setSlop(5)
                    .build()
        }

PhraseQuery는 시작 Term과 끝 Term을 한개씩 지정합니다.

하지만 MultiPhraseQuery는 시작과 끝 Term을 한개 또는 다수로 지정할 수 있습니다.


Regexp Query

정규식을 이용하여 Query를 생성합니다.

"감자와", "감자를"로 시작하는 Term을 모두 찾고 싶다면 아래와 같이 Query를 만들 수 있습니다.

fun getRegexpQuery(): RegexpQuery {
    return RegexpQuery(Term("content", "감자[를와]"))
}

WildQuery와 기능은 동일하지만 좀더 빠른 성능을 가집니다.


지만 WildQuery와 마찬가지로 시작값을 expression으로 하면 모든 term을 순회하기 때문에 매우 느려집니다.


Fuzzy Query

FuzzyQuery는 유사한 단어도 찾도록 도와 줍니다.

Levenshtein 알고리즘을 사용하여 Query의 철자와 대상 Term의 철자가 얼마나 유사한지를 판단합니다.

Levenshtein distance 값으로 유사성을 판단하며, 두개의 text가 같아지기 위해서 발생하는 수정, 삽입, 삭제 횟수로 나타냅니다.

ex) appe -> apple: Levenshtein distance = 1 ("l" 추가가 필요함)

ex) teaching -> teach : Levenshtein distance = 3 ( "ing" 삭제가 필요함) 

fun getFuzzQuery(): FuzzyQuery {
    return FuzzyQuery(Term("content", "감자"), 1)
}


이렇게 검색을 요청하면 위에서 설정한 3개의 문장이 모두 검색됩니다. ("감자와", "감자를")

FuzzyQuery의 생성자는 아래처럼 4개가 존재 합니다.


FuzzyQuery는 CPU를 많이 쓰는 작업이기 때문에 가능한 옵션을 많이 붙여 제한을 두는게 좋습니다.

  • maxEdits: 최대 수정 개수. (0~2)까지 설정 가능.
    • ex) 위 Query에서 "감자" -> "감자와" 또는 "감자를"은 수정거리가 1 입니다. 
  • prefixLength: 수정/삭제등을 하지 않을 접두어의 길이.
    • ex) 위 Query에서 "감자" 는 고정 이므로 2로 설정 하면 불필요한 계산 작업을 줄일 수 있습니다.
  • maxExpansions: 변경을 통하여 찾을 최대 term 개수
    • ex) 1로 설정할경우 "감자를" 찾게되면 더이상 다른 형태로의 변형 검색을 진행하지 않습니다.
    • 따라서 2 이상으로 설정해야 "감자를", "감자와"를 둘다 검색합니다.
    • 만약 Query에 "감자" 가 아닌 "감자를"을 사용했다면 maxExpansions 값을 1로 하더라도 "감자와"가 검색 됐겠죠?
  • transposition: true일 경우 Damerau-Levenshtein distance를 사용하고 false이면 classic Levenshtein distance 알고리즘을 사용합니다.


Range Query

범위를 검색하는 RangeQuery는 문자와 숫자 모두 검색이 가능합니다.
  • TermRangeQuery: 문자의 범위를 검사
  • PointRangeQuery: 숫자의 범위를 검사


TermRangeQuery

아래와 같은 방법으로 Query를 생성합니다.

범위의 시작과 끝을 나타내는 값을 ByteRef 형태로 입력합니다.

ByteRef는 byte[]값을 가지며, 추가적으로 유효한 값이 시작하는 offset 위치와 byte 배열을 length 정보를 추가로 가지고 있는 타입 입니다.

또한 UTF-8을 사용해야 하며, new ByteRef("문자열")로 간단하게 생성할 수 있습니다. (역변환은 utf8ToString()을 사용합니다.)

class QuerySampleMain() {
    ...

    fun createDoc() {
        val writer = makeIndexWriter()

        getContents().forEachIndexed { index, value ->
            val doc = Document().apply {
                add(IntPoint("index", index))
                add(StoredField("index", index))
                add(TextField("content", value, Field.Store.YES))
            }

            writer.addDocument(doc)
        }
        writer.close()
    }

    private fun makeIndexWriter(): IndexWriter {
        //Writer 생성
        val dir = FSDirectory.open(Paths.get(INDEX_PATH))
        val config = IndexWriterConfig(KoreanAnalyzer()).apply {
            openMode = IndexWriterConfig.OpenMode.CREATE
        }
        return IndexWriter(dir, config)
    }

    private fun getContents(): List {
         return listOf("apple", "bear", "banana", "cat", "dog",
                "elephant", "fox", "grape", "horse", "ice cream", "juice", "kiwi")
    }
}

fun main(args: Array) {
    QuerySampleMain().createDoc()

    val query = QuerySample.getStringRangeQuery()
//    val query = QuerySample.getIntRangeQuery()

    val dir = FSDirectory.open(Paths.get(INDEX_PATH))
    val reader = DirectoryReader.open(dir)
    val searcher = IndexSearcher(reader)
    val topDocs = searcher.search(query, 10)

    topDocs.scoreDocs.forEach {
        val document = searcher.doc(it.doc)
        println("id: ${document.get("index")} content:${document.get("content")}")
    }
}


위와같이 간단하게 a~k까지 시작하는 동물, 음식 이름으로 index를 생성합니다.

이때 document 내부에 "content", "index" field를 만들고 각각 값과 순서를 넣습니다.


QuerySample.getStringRangeQuery() 함수는 아래와 같습니다.

fun getStringRangeQuery(): TermRangeQuery {
    return TermRangeQuery.newStringRange("content", "bear", "juice", true, true)
}

"bear" 부터 "juice"까지 검색하면 이 두개 또한 포함 합니다.


결과

id: 1 | content:bear

id: 3 | content:cat

id: 4 | content:dog

id: 5 | content:elephant

id: 6 | content:fox

id: 7 | content:grape

id: 8 | content:horse

id: 9 | content:ice cream

id: 10 | content:juice


"bear"로 시작했으니 "banana"는 포함되지 않았습니다.

문자열 비교는 내부적으로 ByteRef.compare() 함수를 이용하는데, 이는 각각 한 byte씩 비교하기 때문에 두번째 철차가 더 빠른 "banana"는 검색되지 않습니다.

만약 query를 "banana"부터 시작하고 했으면 "bear" 역시 포함 되었겠죠?


PointRangeQuery

숫자를 검색하기 위해 index 시점에 Document field에 IntPoint class를 add했습니다.

또한 xxxPoint 함수는 자체적으로 값을 저장하지 않기 때문에 검색후 값을 반환하기 위해 같은 이름으로 StoredField를 사용하여 따로 저장하였습니다.

이제 위 main 함수에서 아래처럼 주석의 위치를 바꿉니다.

fun main(args: Array) {
    QuerySampleMain().createDoc()

//    val query = QuerySample.getStringRangeQuery()
    val query = QuerySample.getIntRangeQuery()

    ...    
}


QuerySample.getIntRangeQuery()는 아래와 같습니다.

fun getIntRangeQuery(): Query {
    return IntPoint.newRangeQuery("index", 3,6)
}


index field의 값을 3~6까지 검색합니다.

id: 3 | content:cat

id: 4 | content:dog

id: 5 | content:elephant

id: 6 | content:fox

 지정한 값을 포함하여 값이 반환되는걸 알수 있습니다.


Term range query는 모든 term을 대상으로 순회하면서 값을 비교 하기때문에 point range query보다는 확실하게 느립니다.

반응형