본문으로 바로가기
반응형

Apache의 Lucene은 검색을 위한 라이브러리 입니다.

이 라이브러리를 기반으로 하여 Apache Solr 또는 ElasticSearch가 구동됩니다.

코드는 자바로 되어이어 자바로 코드를 작성하면 됩니다.

다만 여기서는 코드 간소화및 효율화를 위해 kotlin으로 예제를 작성하였습니다.


일단 간단한 예제로 루씬의 사용법을 설명합니다.

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


Maven 설정

pom.xml 파일에 아래와 같이 dependency를 추가합니다.


Sample data download

간단하게 몇몇 text를 만들어서 예제로 사용해도 좋지만, 실제와 유사하게 대량(??)의 데이터를 입력하고 해당 데이터를 검색하는 형태로 진행하기 위해 미국 항공 기록 데이터를 샘플로 사용하겠습니다.

먼저 하기 링크에 들어가서 다운로드 페이지에 접근합니다.



저는 2008 자료만 다운받았습니다.

다운로드된 파일을 풀면 csv 파일이 있으며, 데이터만 남기기 위해 첫줄을 삭제합니다.

또한 다운로드 페이지에 각 컬럼이 어떤 데이터를 의미하는지 나와 있습니다.

csv 파일을 열어 첫줄을 지우고 아래와 같이 데이터만 존재하도록 변경합니다.


2008년 데이터만 받았으니 당연히 year는 2008년만 있겠죠?


Model 객체 생성

해당 파일은 자바에서 읽어서 parsing해서 사용해야 합니다.

따라서 아래와 같이 해당 데이터를 담을 객체를 준비합니다.

class AirlineInfo(readLine: String) {

    val year: String
    val month: String
    val carrier: String
    val delayArrivalTime: String
    val delayDepartureTime: String
    val origin: String
    val destination: String
    val distance: String

    private val parsedInfo: List

    init {
        parsedInfo = readLine.split(",")

        parsedInfo.apply {
            year = get(0)
            month = get(1)
            carrier = get(8)
            delayArrivalTime = if (get(14) == "NA") {
                ""
            } else {
                get(14)
            }
            delayDepartureTime = if (get(15) == "NA") {
                ""
            } else {
                get(15)
            }
            origin = get(16)
            destination = get(17)
            distance = get(18)
        }
    }
}

데이터를 전부 다 사용하지는 않을꺼라서 하기 목록만 파싱해서 데이터 모델로 만들 예정입니다.

  • year: 연도
  • month: 월
  • carrier: 항공사 고유 코드
  • delayArrivalTime: 연착시간
  • delayDepartuerTime: 출발 지연 시간
  • origin: 출발지
  • destination: 목적지
  • distance: 비행거리


이제 사용할 데이터는 다 준비 됐습니다.

이 데이터를 가지고 index를 만들어 봅니다.


Index writing

org.apache.lucene.index.IndexWriter는 index의 생성과 관리를 담당하는 package 입니다.
indexWriter를 생성시 저장할 FSDirectoryindexWriterConfig를 넘겨주면 됩니다.
indexWriter를 생성한 이후에 indexWriterConfig는 다른 indexWriter에서 재활용 할 수 없습니다.

import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.document.Document
import org.apache.lucene.document.Field
import org.apache.lucene.document.StringField
import org.apache.lucene.document.TextField
import org.apache.lucene.index.IndexWriter
import org.apache.lucene.index.IndexWriterConfig
import org.apache.lucene.store.FSDirectory
import java.nio.file.Paths


class DocumentWriter {
    companion object {
        // index를 저장할 경로 window = c:/Temp... || unix 계열은 아래처럼...
        const val INDEX_PATH = "/Users/myhome/workspace/LuceneTest/index"
    }

    fun createDoc(info: AirlineInfo): Document {
        return Document().apply {
            add(StringField("year", info.year, Field.Store.YES))
            add(StringField("month", info.month, Field.Store.YES))
            add(TextField("carrier", info.carrier, Field.Store.YES))
            add(StringField("delayArrivalTime", info.delayArrivalTime, Field.Store.YES))
add(StringField("delayDepartureTime", info.delayDepartureTime, Field.Store.YES))
add(TextField("destination", info.destination, Field.Store.YES)) add(StringField("distance", info.distance, Field.Store.YES))
add(TextField("origin", info.origin, Field.Store.YES)) } } fun makeIndexWriter(): IndexWriter { //Writer 생성 val dir = FSDirectory.open(Paths.get(INDEX_PATH)) val config = IndexWriterConfig(StandardAnalyzer()) return IndexWriter(dir, config) } }

여기에 추가적으로 createDoc() 이라는 document를 만드는 함수를 추가했습니다.

AirlineInfo data model을 받으면 이를 document로 만들어 반환합니다.

StringField와 TextField는 tokenize를 할지 말지의 여부에 따라 사용을 결정합니다.

여기서는 숫자값은 StringField, 문자로 되있는 값은 TextField로 만들어 넣었습니다.

(하지만 숫자든, 문자든 전부 데이터 타입은 String 입니다.)

또한 마지막 인자값으로 Field.Store.YES 를 주어 전부 인덱스로 만들도록 합니다.


Writer를 위한 helper 클래스(DocumentWriter)를 만들었으니, main 함수를 작성해 봅니다.

import java.io.File
import kotlin.system.measureTimeMillis

fun main(args: Array) {
    val elapsedTime = measureTimeMillis {
        //SourceFile 경로
        val SOURCE_PATH = "/Users/myhome/workspace/LuceneTest/2008.csv"

        val bufferedReader = File(SOURCE_PATH).bufferedReader()

        val docWriter = DocumentWriter()
        val indexWriter = docWriter.makeIndexWriter()

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

        bufferedReader.forEachLine {

            // document 생성후 add
            val doc = docWriter.createDoc(AirlineInfo(it))
            indexWriter.addDocument(doc)

        }
        indexWriter.commit()
        indexWriter.close()
    }

    println("elapsed time:$elapsedTime")
}


자바에서 일반 파일을 읽듯이 파일을 읽어 들입니다.

단 코틀린 문법이라 File().bufferedReader()를 사용하였으나, 자바라면 path를 열어 buffered stream이나 file stream을 열어서 사용하면 됩니다.


파일 read buffer를 생성한 후에는 indexWriter를 생성하고, 기존 값을 한번 초기화 합니다. 

그런후에 bufferedReader.forEachLine{...}으로 한줄씩 일어 AirlinInfo 객체로 만들어 파싱한후, 해당 데이터를 미리 구현해 놓은 creareDoc() 함수를 이용하여 Document 객체로 만듭니다.

모든 라인을 읽으면 writer를 commit()하고, close() 합니다. 


자바 였다면 read stream을 while 문으로 한줄씩 읽으면서 docuemnt를 생성하여 writer로 쓰기를 수행하면됩니다.

구현상 단계적인 처리를 위해 buffered reader로 line별로 파싱하여 AirlinInfo 객체나 이를 파싱한 Document 객체를 list로 만들고, 이 list를  indexWriter.addDocuments() 라는 메서드로 한번에 써도 됩니다.

addDocuments()는 list를 받아 list를 한번에 index로 write합니다.


다만 다운로드 받은 2008 항공 데이터는 크기가 600MB가 넘습니다.

소량의 데이터라면 상관없지만 단계별로 전부 읽어 메모리에 list로 올려놓고, 한번에 이 list를 writer로 쓰도록 한다면, list를 만들다가 heap 메모리 부족으로 application이 죽습니다.


해당 main 함수를 수행하면 아래와 같이 나옵니다.


제 PC에서는 dir 21초정도 걸렸네요.

지정해 놓은 index 폴더에 가보면 index가 생성된걸 볼수 있습니다.

index 폴더 용량만 140MB정도 되네요..


Search

Search를 하기위해 간단한 Helper 클래스를 먼저 만들어 봅니다.
class LuceneSearch {
    companion object {
        const val INDEX_PATH = "/Users/mydhome/workspace/LuceneTest/index"
    }

    private val searcher = getIndexSearcher()

    //Search 생성
    private fun getIndexSearcher(): IndexSearcher {
        val dir = FSDirectory.open(Paths.get(INDEX_PATH))
        val reader = DirectoryReader.open(dir)
        return IndexSearcher(reader)
    }

    fun search(column: String, text: String): TopDocs {
        val qp = QueryParser(column, StandardAnalyzer())
        val query = qp.parse(text)
        return searcher.search(query, 10)
    }

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


compainon object{...} 안에 넣었으니, 해당 함수들은 java로 말하면 static 함수 입니다.

먼저 searcher 멤버변수를 정하고 searcher를 한번만 생성해서 넣습니다.


getIndexSearcher()를 indexSearcher의 객체를 생성해서 반환합니다.

index가 저장된 경로를 이용하여 FSDirectory를 open 합니다.

이렇게 만들어진 FSDirectory로 DirectoryReader를 만듭니다.

IndexSearcher 생성시 DirectoryReader를 인자로 받습니다.


search() 함수는 QueryParser를 이용하여  query를 파싱한후에 검색합니다.

이때 결과는 상위 10개만 뽑습니다.


getDoc() 함수는 검색되어나오 docid를 searcher에 넣어 Document 객체를 반환 받습니다.


이제 마지막으로 검색을 진행하는 main 함수입니다.

class LuceneSearch {
    companion object {
        const val INDEX_PATH = "/Users/myhome/workspace/LuceneTest/index"
    }

    private val searcher = getIndexSearcher()

    //Search 생성
    private fun getIndexSearcher(): IndexSearcher {
        ...
    }

    fun search(column: String, text: String): TopDocs {
       ...
    }

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

fun main(args: Array) {
    val airlineSearch = LuceneSearch()

    val topDocs = airlineSearch.search("destination", "LAX")
    println("searchCount = ${topDocs.totalHits}")
    topDocs.scoreDocs.forEach {
        val document = airlineSearch.getDoc(it.doc)
        println("carrier: ${document.get("carrier")} | " +
                "month:${document.get("month")} | " +
                "origin:${document.get("origin")} | " +
                "destination:${document["destination"]}")
    }
}


Helper 클래스 (LuceneSearch())를 생성한 후에 검색할 column (name) 과 value를 입력합니다.

search를 진행하며 TopDocs 객체가 반환됩니다.

여기서 topDocs.scoreDocs()를 통해서 검색된 최대 10개정보가 반환됩니다.

forEach{...}문으로 돌면서 socreDocsdoc.doc()를 호출하여 doc id를 얻어 helper class의 getDoc() 함수를 통해 Document객체로 만들어 출력합니다.



예제 코드처럼 목적지를 "LAX"로 검색해 보면 총 1395개 이상이 검색 되었으며 그중에 상위랭크 10개는 위와 같습니다.

전부 라스베가스에서 출발해서 로스엔젤레스로 간 비행기네요.

반응형