본문으로 바로가기
반응형

이번에는 문자열을 검색하고 이를 highlight 처리하는 방법에 대해서 알아봅니다.

먼저 highlight 기능을 이용하려면 dependency에 highliter를 추가해야 합니다.

maven project의 pom.xml 파일에 하기와 같이 추가하면 됩니다.



Index writing

폴더에 파일을 넣어두고 이 파일들의 내용을 검색해서 사용합니다.
아래 링크에서 사용했던 방법과 동일한 방법으로 index를 생성합니다.

index를 생성하는건 동일한 코드이므로 코드에 대한 자세한 설명이 필요하다면 상기의 링크를 클릭하여 먼저 확인하시기 바랍니다.

1. Write helper class 생성
class DocumentWriter2 {
    companion object {
        const val INDEX_PATH = "/Users/myhome/workspace/LuceneTest/index2"
    }

    val indexWriter = makeIndexWriter()

    fun createDoc(path: Path, attrs: BasicFileAttributes): Document {
        return Document().apply {
            add(StringField("path", path.toString(), Field.Store.YES))
            add(TextField("content", File(path.toString()).readText(), Field.Store.YES))
            add(LongPoint("size", attrs.size()))
            add(StringField("size", attrs.size().toString(), Field.Store.YES))
        }
    }

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


2. Write main class 작성

fun main(args: Array<String>) {

    // SourceFile 경로
    val TARGET_PATH = "/Users/myhome/workspace/LuceneTest/searchTarget"

    val docWriter = DocumentWriter2()
    val path = Paths.get(TARGET_PATH)

    // 일단 초기화
    docWriter.indexWriter.deleteAll()

    // nio 패키지를 이용란 파일 전체 순회 처리
    Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
        @Throws(IOException::class)
        override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
            try {
                //document 생성.
                val doc = docWriter.createDoc(file, attrs)
                //index 생성 -> path가 같을 경우 덮어쓰기.
                docWriter.indexWriter.updateDocument(Term("path", file.toString()), doc)
            } catch (e: IOException) {
                e.printStackTrace()
            }

            return FileVisitResult.CONTINUE
        }
    })

    docWriter.indexWriter.close()
}



Search and Highlight

먼저 index를 검색할 search class의 helper를 만듭니다.
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.index.DirectoryReader
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.search.IndexSearcher
import org.apache.lucene.search.Query
import org.apache.lucene.search.highlight.*
import org.apache.lucene.store.FSDirectory
import java.nio.file.Paths

class FileSearch2 {
    companion object {
        const val INDEX_PATH = "/Users/myhome/workspace/LuceneTest/index2"
    }

    // reader lazy initialize
    val reader: DirectoryReader by lazy {
        val dir = FSDirectory.open(Paths.get(INDEX_PATH))
        DirectoryReader.open(dir)
    }

    // search lazy initialize
    private val searcher: IndexSearcher by lazy { IndexSearcher(reader) }

    fun getDoc(docid: Int) = searcher.doc(docid)

    fun search(query: Query) = searcher.search(query, 10)

    fun getQuery(column: String, text: String): Query {
        val qp = QueryParser(column, StandardAnalyzer())
        return qp.parse(text)
    }

    // Highlighter 생성
    fun getHighlighter(query: Query): Highlighter {
        // 발견된 개수에 따른 점수환산 및 SpanQuery로 변경
        val queryScorer = QueryScorer(query)

        val highlightStartTag ="<b><span style='color: rgb(255, 0, 0)'>"
        val highlightEndTag = "</span></b>"
        val formatter = SimpleHTMLFormatter(highlightStartTag, highlightEndTag)

        return Highlighter(formatter, queryScorer).apply {
            // text를 동일한 크기의 text fragment로 쪼갠다.
            textFragmenter = SimpleSpanFragmenter(queryScorer)
        }
    }
}

helper의 내용은 앞쪽의 다른 예제들과 다르지 않습니다.

DirectoryReaderIndexSearcher는 kolin의 lazy keyword를 사용하여 lazy initialize 시켰습니다만, java로 구성시 그냥 메번 변수에 바로 할당하여 사용해도 상관없습니다. (java에서 lazy initialize를 구현하려면 좀더 수고스럽기도 하고, 꼭 필요하여 lazy 처리한건 아닙니다.) 


getHighlighter() 함수는 Highligter를 생성합니다.

QueryScorer

검색시 발견된 unique한 query term의 개수에 따라 점수를 매기는 클래스이며 Scorer를 상속받아 구현한 클래스 입니다.
이 클래스는 query를 spanQuery로 변환합니다. 또한 문서의 hit값을 올리는데 참여한 terms에 대해서만 점수를 매깁니다.

spanQuery는 찾아야 하는 글자들의 집합이나, 집합한 글자간의 거리, 순서등을 제한하여 검색할수 있는 query이며, 자세한 내용은 하기 링크에서 확인하시면 됩니다.

https://tourspace.tistory.com/241


SimpleHTMLFormatter

Highlight 구문을 표시할때 강조표시를 HTML로 합니다.

생성자 없이 사용할 경우 기본적으로 <b> 태크를 이용하여 bold 처리합니다.

코드에서는 bold 처리및 색상도 빨간색으로 표시합니다.

그외에도 Formatter를 상속받아 구현한 클래스로 GradientFormatter, GradientSpanFromatter가 있으며, 이 formatter 사용시 배경색을 지정할 수 있습니다.


SimpleSpanFragmenter

검색된 글자를 어떻게 잘라낼지를 setTextFragmenter() 함수를 이용하여 Highlighter에 설정합니다.

SimpleSpanFragmenter는 Fragmenter를 상속받아 구현한 클래스로 , text를 동일한 크기의 조각으로 잘라냅니다. 

다만 span된 text는 자르지 않습니다.

만약 SimpleFragmenter를 사용한다면, 검색된 글자만 간추려서 간략하게 나타납니다.


fun main(args: Array<String>) {
    val fileSearcher = FileSearch2()

    // 검색할 정보
    val field = "content"
    val text = "lucene core project query"

    // content에서 lucene 검색
    val query = fileSearcher.getQuery(field, text)
    val topDocs = fileSearcher.search(query)

    println("searchCount = ${topDocs.totalHits}")

    val highlighter = fileSearcher.getHighlighter(query)

    topDocs.scoreDocs.forEach {
        val document = fileSearcher.getDoc(it.doc)
        println("file: ${document.get("path")} | " +
                "size:${document.get("size")}")

        // Highlight
        val termVector = fileSearcher.reader.getTermVectors(it.doc)
        val content = document.get(field)
        val tokenStream = TokenSources.getTokenStream(field,termVector, content, StandardAnalyzer(), -1)
        val fragments = highlighter.getBestFragments(tokenStream, content, 10)

        fragments.forEach {
            println("content: $it")
            println("")
        }
    }
}
기본적으로 문자열을 검색하는 방법은 예전 예제와 동일합니다.

IndexSearcher에 query를 넘겨 TopDocs를 반환받습니다.

TopDocs를 순회하면서 document를 얻어와 정보를 출력합니다.


이때 highlight를 하기 위해서 token stream을 만들어 highlighter에게 넘겨주면 highlight된 text fragments들을 얻을 수 있습니다.


Token Stream은 TokenSourcesgetTokenStream() 함수를 이용하여 얻습니다.

getTokenStream()함수의 인자로 termVector (반환 타입 Fileds)정보를 넣어 줍니다.

이는 DirectoryReadergetTermVectors()를 통해서 얻을수 있습니다.

다만 이 예제에서는 index를 write 할때 TextField로 만들어 넣었습니다.

TextField는 termVector를 생성하지 않는 field type 이므로 getTermVectors() 호출시 null이 반환됩니다. (따라서 이 예제에서는 해당 getTokenStream()의 termVector param을 null을 넣었어도 됩니다.)

또한 termVector param의 값이 null 이면 content param(검색 대상이 되는 전체 구문)을 이용하여 해당 문구를 다시 분석합니다.


결과는 아래와 같이나옵니다.

이쁘게 원하는 코드들이 잘 검색 됐네요~~


searchCount = 3 hits

file: /Users/1111336/workspace/LuceneTest/searchTarget/text1 | size:428

content: Lucene Core, our flagship sub-project, provides Java-based indexing and search technology, as well as spellchecking, hit highlighting and advanced analysis/tokenization capabilities. SolrTM is a high performance search server built using Lucene Core, with XML/HTTP and JSON/Python/Ruby APIs, hit highlighting, faceted search, caching, replication, and a web admin interface. PyLucene is a Python wrapper around the Core project

file: /Users/1111336/workspace/LuceneTest/searchTarget/text3 | size:696 

content: New Features New XYShape Field and Queries for indexing and querying general cartesian geometries. Snowball stemmer/analyzer for the Estonian language. Provide a FeatureSortfield to allow sorting search hits by descending value of a feature. Add new KoreanNumberFilter that can change Hangul character to number and process decimal point. Add doc-value support to range fields. Add monitor subproject (previously Luwak monitoring library) that allows a stream of documents to be matched against a set of registered queriesin an efficient manner. Add a numeric range query in sandbox that takes advantage of index sorting.Add a numeric range query in sandbox that takes advantage of index sorting. 

file: /Users/1111336/workspace/LuceneTest/searchTarget/text2 | size:740

content: The Lucene project has added two new announce mailing lists, issues@lucene.apache.org and builds@lucene.apache.org. High-volume automated emails from our bug tracker, JIRA and GitHub will be moved from the dev@ list to issues@ and automated emails from our Jenkins CI build servers will be moved from the dev@ list to builds@. This is an effort to reduce the sometimes overwhelming email volume on our main development mailing list and thus make it easier for the community to follow important discussions by humans on the dev@lucene.apache.org list. Everyone who wants to continue receiving these automated emails should sign up for one or both of the two new lists. Sign-up instructions can be found on the Lucene-java and Solr web sites.


UnifiedHighlighter

UnifiedHighlighter는 문서의 내용이 많을 경우 성능이 좀더 좋은 Highlighter 입니다.
이 Highlighter는 offset을 이용하여 동작합니다.
따라서 Index option에 "IndexOptions.DOC_AND_FREQs_AND_POSITIONS_AND_OFFSET"을 사용하거나, term vector를 사용하거나, Offsets.. 또는 둘다 없을 경우 text를 재분석하여 offset을 얻습니다.

동작방식
이 Highlighter는 하나의 단일 document를 전체인 것처럼 취급하고 문서 내의 구절(passage)을 각각의 document인것 처럼 취급하여 점수를 매깁니다.
Text에서 구절(passage)를 찾기 위해서 BreakIterator를 사용하며, 이는 기본적으로 getSentenceInstance(Locale.Root)를 이용하여
구절로 분리합니다.
그 다음 Query의 모든 terms의 위치를 이용하여 병렬로 순회하여 (offset을 이용한 merge sorting) 단일 구문에서 발생하는 hit을 Passage단위로 통합하고 각 Passage에 대한 점수를 PassageScorer를 통해서 매깁니다.
이 Passage들은 PassageFormatter에 의해 highlight snippets으로 변경됩니다.

정리하면
1. 하나의 문서를 검색의 대상의 전체로 다룸.
2. 문서를 구절로 분리하여 하나의 document처럼 취급
3. offset을 이용해 query를 병렬 순회
4. 이렇게 찾아진 hit를 Passage 단위로 묶어서 합침
5. hit가 통합된 Passage를 PassageScorer를 통해 scoring
6. PassageFormatter로 highlight돤 snippet으로 변경하여 반환


먼저 상단에서 만들었던 Helper class의 최 하단에 UnifiedHighlighter를 반환하는 함수를 하나 추가합니다.

class FileSearch2 {
    companion object {
        const val INDEX_PATH = "/Users/myhome/workspace/LuceneTest/index2"
    }

    // reader lazy initialize
    val reader: DirectoryReader by lazy {
        ...
    }

    // search lazy initialize
    private val searcher: IndexSearcher by lazy { IndexSearcher(reader) }

    fun getDoc(docid: Int) = searcher.doc(docid)

    fun search(query: Query) = searcher.search(query, 10)

    fun getQuery(column: String, text: String): Query {
        ...
    }

    // Highlighter 생성
    fun getHighlighter(query: Query): Highlighter {
        // 발견된 개수에 따른 점수환산 및 SpanQuery로 변경
        ...
    }

    fun getUnifiedHighlighter() = UnifiedHighlighter(searcher, StandardAnalyzer())
}


그리고 마찬가지로 main() 함수에서 기존 Highlighter를 주석 처리하고 UnifiedHighlighter를 사용하도록 추가 합니다.

fun main(args: Array) {
    ...
//    val highlighter = fileSearcher.getHighlighter(query)
    val highlighter = fileSearcher.getUnifiedHighliter()

    topDocs.scoreDocs.forEach {
        val document = fileSearcher.getDoc(it.doc)
        println("file: ${document.get("path")} | " +
                "size:${document.get("size")}")

        // Highlight
//        val termVector = fileSearcher.reader.getTermVectors(it.doc)
//        val content = document.get(field)
//        val tokenStream = TokenSources.getTokenStream(field, termVector, content, StandardAnalyzer(), -1)
//        val fragments = highlighter.getBestFragments(tokenStream, content, 10)

        //UnifiedHighlighter
        val fragments = highlighter.highlight(field, query, topDocs)

        fragments.forEach {
            println("content: $it")
            println("")
        }
    }
}


검색 결과는 아래와 같습니다.

searchCount = 3 hits

file: /Users/myhome/workspace/LuceneTest/searchTarget/text1 | size:428 

content: PyLucene is a Python wrapper around the Core project

content: Add a numeric range query in sandbox that takes advantage of index sorting.Add a numeric range query in sandbox that takes advantage of index sorting.

content: The Lucene project has added two new announce mailing lists, issues@lucene.apache.org and builds@lucene.apache.org. 


file: /Users/myhome/workspace/LuceneTest/searchTarget/text3 | size:696 

content: PyLucene is a Python wrapper around the Core project

content: Add a numeric range query in sandbox that takes advantage of index sorting.Add a numeric range query in sandbox that takes advantage of index sorting. 

content: The Lucene project has added two new announce mailing lists, issues@lucene.apache.org and builds@lucene.apache.org. 


file: /Users/myhome/workspace/LuceneTest/searchTarget/text2 | size:740

content: PyLucene is a Python wrapper around the Core project. content: Add a numeric range query in sandbox that takes advantage of index sorting.Add a numeric range query in sandbox that takes advantage of index sorting. content: The Lucene project has added two new announce mailing lists, issues@lucene.apache.org and builds@lucene.apache.org. Process finished with exit code 0

반응형