본문으로 바로가기
반응형

검색시 명확하게 특정단어를 검색할수도 있지만 유사한 단어나 오타가 발생할 때에도 검색되도록 하도록 동의어(유의어)를 등록 가능하도록 lucene에서 지원합니다.

이는 core 라이브러리가 아니기 때문에 maven pom.xml 파일에 아래와 같이 depedency를 추가해야 합니다.


이 글은 lucene V8.2.0 기준으로 작성되었습니다.

모든 예제코드는 Kotlin으로 작성되었습니다.

검색시 동의어 설정

"와치" 로 검색하면 "워치"도 알아서 검색해 주면 좋으련만, 아쉽게도 동의어는 수동으로 입력해 줘야 합니다.
"블루투스"를 검색할때 "블투", "불투", "BT", "Bluetooth", "Blue tooth" 등등의 예상되는 동의어를 잘 생각해서 만들어야 되겠네요.

동의어 검색을 위해서는 아래 두가지 Class를 이용합니다.
  • SynonymGraphFilter
  • SynonymMap
먼저 동의어 검색을 위해 Analyzer를 custom하게 만듭니다.
class SynonymAnalyzer(private val synonymMap: SynonymMap) : Analyzer() {
    override fun createComponents(fieldName: String): TokenStreamComponents {
        val tokenizer = StandardTokenizer()
        val synonymGraphFilter = SynonymGraphFilter(tokenizer, synonymMap, true)
        return TokenStreamComponents(tokenizer, synonymGraphFilter)       
    }
}

Tokenizer는 StandardTokenizer를 이용합니다.

그리고 SynonymGraphFilter에 동의어가 등록된 SynonymMap을  넣어 Filter를 등록해 줍니다.

Custom Analyzer 만들기 쉽죠?


이제 검색을 도와줄 Helper class를 만들어 봅니다.

class SynonymHelper {
    companion object {
        const val INDEX_PATH = "/Users/myhome/workspace/LuceneTest/index3"
    }

    fun createDoc(content: String): Document {
        return Document().apply {
            add(TextField("contents", content, Field.Store.YES))
        }
    }

    fun getSampleText(): List {
        return listOf(
                "Time is gold",
                "one minute is made by 60 seconds",
                "It takes an hour",
                "My birth day is 11th Nov.",
                "November is winter"
        )
    }

    fun search(dir: Directory, query: Query) {
        try {
            DirectoryReader.open(dir).use{ reader ->
                val searcher = IndexSearcher(reader)
                val docs = searcher.search(query, 10)
                val hits = docs.scoreDocs

                println("검색된 개수: ${hits.size}")
                hits.indices.forEach{
                    val docId = hits[it].doc
                    val doc = searcher.doc(docId)
                    println("result: ${doc.get("contents")}")
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

1. createDoc()

"contents" field를 갖는 Document를 생성합니다. 이때 넘겨받은 Text가 해당 Field에 저장됩니다.


2. getSampleText()

총 5개의 문장을 등록했습니다.


3. search()

요청받은 query를 검색하여 println 으로 출력합니다.


이제 검색을 위한 main 함수를 만들어 봅니다.

fun main(args: Array) {
    /***** 인덱스 생성 *****/
    //Writer 생성
    val dir = FSDirectory.open(Paths.get(INDEX_PATH))
    val config = IndexWriterConfig(StandardAnalyzer()).apply {
        openMode = IndexWriterConfig.OpenMode.CREATE
    }
    val indexWriter = IndexWriter(dir, config)

    // document 생성 및 index 추가
    val helper = SynonymHelper()
    val sampleTexts = helper.getSampleText()
    val docs = sampleTexts.forEach {
        val doc = helper.createDoc(it)
        indexWriter.addDocument(doc)
    }
    indexWriter.close()

    /***** 유의어 검색 *****/
    // 유의어 설정
    val synonymMap = SynonymMap.Builder(true).run {
        add(CharsRef("time"), CharsRef("hour"), true)
        add(CharsRef("time"), CharsRef("minute"), true)
        add(CharsRef("time"), CharsRef("second"), true)
        add(CharsRef("nov"), CharsRef("november"), true)
        build()
    }

    val query = QueryParser("contents", SynonymAnalyzer(synonymMap)).parse("Time")
    helper.search(dir, query)
}

indexWriter를 생성한 후에 SampleText들을 index에 추가합니다.

main함수는 query를 바꿔가면서 여러번 실행 할 예정이므로 IndexWriterConfig의 openMode를 OpenMode.CREATE로 설정합니다.
OpenMode.CREATE는 인덱스를 매번 생성합니다. (기존 index가 있다면 덮어쓰기)
이 값을 설정하지 않으면 기본값으로 OpenMode.CREATE_OR_APPEND가 설정되어 main를 수행할때마다 동일한 index가 중복되어 등록됩니다.

유의어를 등록합니다.
"time"이라는 값을 검색시 유사어로 "hour" "minute" "second"를 등록하고 마지막 param을 true로 설정하여 original 값도 검색어에 포함 시킵니다.

검색어를 "Time"으로 하여 검색하면 아래와 같은 결과가 출력됩니다.
검색된 개수: 2 

result: It takes an hour 
result: one minute is made by 60 seconds

으로 검색하여 나온결과에 유사어로 등록한 "hour", "minute"가 추가된 문장들이 검색됩니다.

엇...그런데 정작 "Time is gold"는 검색이 안됐습니다.

그럼 검색어를 "time"로 넣으면 어떻게 될까요?

...
val query = QueryParser("contents", SynonymAnalyzer(synonymMap)).parse("time")
...


이렇게 넣어서 검색을 진행하면 아래와 같이 검색됩니다.

검색된 개수: 3

result: Time is gold

result: It takes an hour

result: one minute is made by 60 seconds


먼저 위에서 Analyzer를 생성할때 SynonymGraphFilter의 ignoreCase 값을 true로 넣었습니다.

 override fun createComponents(fieldName: String): TokenStreamComponents {
        val tokenizer = StandardTokenizer()
        val synonymGraphFilter = SynonymGraphFilter(tokenizer, synonymMap, true)
        return TokenStreamComponents(tokenizer, synonymGraphFilter)
...


이렇게 설정하면 QueryParser로 parse할때 넘겨받은 text을 무조건 소문자로 변경합니다.

따라서 "Time"을 넣든 "time" 넣든 동의어의 key값 검색은 "time"으로 됩니다.

이는 SynonymMap을 만들때는 무조건 소문자로 값을 설정해야 한다는 의미 입니다.

(synonymMap에 등록된 문자까지 소문자로 치환후 비교하지 않습니다.)


그럼 다음 설명을 하기전에 Index를 만들때 사용했던 StandardAnalyzer가 term을 어떻게 생성 했는지를 봐야 합니다.

Helper에 아래와 같이 term을 어떻게 쪼개는지를 확인할수 있는 함수를 생성 합니다.

class SynonymHelper {
    ...

    fun createDoc(content: String): Document {
       ...
    }

    fun getSampleText(): List {
        ...
    }

    fun search(dir: Directory, query: Query) {
       ...
    }

    fun analyzeText(analyzer: org.apache.lucene.analysis.Analyzer, text: String) {
        val tokenStream = analyzer.tokenStream("contents", text)
        val term = tokenStream.addAttribute(CharTermAttribute::class.java)

        tokenStream.use {
            it.reset()
            print("$text -> ")
            while(it.incrementToken()) {
                print("$term | ")
            }
            it.end()
            println("")
        }
    }
}

그리고 main 함수에서 Term을 어떻게 쪼갰는지 확인해 봅니다.

fun main(args: Array) {
   ...

    sampleTexts.forEach {
        helper.analyzeText(StandardAnalyzer(), it)
    }
}


결과

Time is gold -> time | is | gold | 

one minute is made by 60 seconds -> one | minute | is | made | by | 60 | seconds | 

It takes an hour -> it | takes | an | hour | 

My birth day is 11th Nov. -> my | birth | day | is | 11th | nov | 

November is winter -> november | is | winter |


결과를 보면 StandardAnalyzer전부 소문자 변경, 특수문자제거 ( ex) Nov. -> nov)가 된걸 확인할 수 있습니다.

따라서 "Time" 으로 검색하면 동의어 찾을땐 등록된 값 "time"로 변경하여 검색하여 결과를 반환하고, 원문이 "time" "is" "gold" 은 검색이 안되는거죠

("Time" != "time")

 반대로 "time"으로 검색시 동의어의 의한 검색과 원문의 term인 "time" "is" "gold"이 같이 검색됩니다.


결론적으로 원인이야 어쨌든 일반적으로 대소문자를 가지고 저렇게 다른게 동작하는걸 원하지 않습니다.

따라서 Analyzer에 LowerCaseFilter를 추가하여 원문 검색시에도 소문자로 변경하여 검색하도록 해야 Query의 대소문자와 상관없이 결과가 동일하게 나옵니다.

class SynonymAnalyzer(private val synonymMap: SynonymMap) : Analyzer() {
    override fun createComponents(fieldName: String): TokenStreamComponents {
        val tokenizer = StandardTokenizer()
        
        /**
        val synonymGraphFilter = SynonymGraphFilter(tokenizer, synonymMap, false)
        return TokenStreamComponents(tokenizer, synonymGraphFilter)
        */
        
        val synonymGraphFilter = SynonymGraphFilter(tokenizer, synonymMap, true)
        val lowerCaseFilter = LowerCaseFilter(synonymGraphFilter)
        return TokenStreamComponents(tokenizer, lowerCaseFilter)        
    }
}


Index 생성시 동의어 설정

Index 생성시나, 검색시점에 공통으로 사용하던 6.5.0 버전부터 SynonymFilter가 Deprecate 되었습니다.
따라서 검색시점에는 위 코드와 같이 사용이 가능하나, index 시점에서 동의어 설정시 FlattenGraphFilter를 같이 쓰는것을 권고하고 있습니다.

하지만 직접 구현시 동작이 되지 않네요.ㅠ.ㅠ
이건 나중에 방법이 생기면 추후 update 하도록 하겠습니다.


반응형