본문 바로가기

스크랩/전산/전자

풀 텍스트 검색엔진 Lucene 2부

 
풀 텍스트 검색엔진 Lucene 2부
[ 조회수: 2223 ]
 





  ( 월간 마이크로 소프트웨어 9월 연재 )




서문
지난 호에서는 lucene의 소개와 함께 기본적인 내용에 대해서 다루어 보았다. 이번 호에서는 Apache Lucene 의 코어 클래스 위주로 깊이 있게 다루고자 한다.

Lucene의 핵심 요소인 Analyzer와 인덱스 튜닝 그리고 고급 검색기법 등에 대해 다루어 보고자 한다. 또한 이슈가 되고 있는 여러 문제점과 해결방안에 대해서도 같이 알아보자.



1. Analyzer
지난 회에선 Apache Lucene이 기본적으로 제공하는 4가지 built-in Analyzer에 대해 살펴 보았다. 이번 회 에서는 Analyzer에 대해 좀 더 상세히 살펴보고 Analyzer를 커스터마이징 하고 직접 작성해 보자. Lucene API의 org.apache.lucene.analysis 패키지를 보면 4개의 built-in Analyzer ( StopAnalyzer, SimpleAnalyzer, WhitespaceAnalyzer, StandardAnalyzer ) 와 함께 Token, Tokenizer, TokenStream, TokenFilter 등이 있다.
이중 주의 깊게 살펴 보아야 할게 Tokenizer 와 TokenFilter 인데 이 두 클래스가 Analyzer를 구성하는 핵심 요소가 된다. Tokenizer 와 TokenFilter는 모두 TokenStream을 상속받은 자식 클래스 로서 input 값을 입력 받아 Token 단위로 다양한 처리를 한다. 이 두 클래스의 차이점으로는  Tokenizer는 개별 문자(characters) 단위로 데이터를 처리하고 TokenFilter는 단어(words) 단위로 처리를 하며 Tokenizer는 Reader 타입의 입력 값을 받아서 처리하는 반면 TokenFilter는 아래 <그림1>의 클래스 계층 구조도 에서 짐작 할 수 있듯이 부모 객체인 TokenStream 형태로 입력 값을 받아서 사용한다. <그림1>은 lucene 에서 사용되는 TokenStream의 클래스 계층 구조도 인데 클래스 명만 봐도 어떤 역할을 하는지 대략 상상 할 수 있을 것 이다. 각각의 자세한 사용법이나 설명은 API를 참고 하도록 하고 이제 이러한 TokenStream을 조합해서 간단한 Analyzer를 작성해 보도록 하자.
실행 가능한 전체 소스는 '이달에 디스크'를 통해 확인할 수 있으며, <리스트1>에서는 핵심 코드만 추출하였다. TestAnalyzer 라는 새로운 Analyzer를 정의 하기 위해서 부모 클래스인 Analyzer를 상속 받고 tokenStream() 메소드를 재정의 해주면 된다.
이때 앞서 설명한 TokenFilter의 강력한 메커니즘을 엿볼 수 있는데 <리스트1>에서 tokenStream() 메소드 부분을 보면 다양한 Filter들을 TokenStream result = new LowerCaseFilter(result) 와 같은 방법으로 손쉽게 적용 하였다. 이는 TokenFilter가 TokenStream을 상속받으면서 입력값으로 TokenStream을 받아서 사용하기에 가능한 일이다. 전체 소스에서는 StandardToekenizer, StandardFilter, LowerCaseFilter, StopFilter 등을 사용하였다.







































<그림1> TokenStream의 클래스 계층 구조도





<리스트1> TestAnalyzer.java

public class TestAnalyzer extends Analyzer {
…..
   public TokenStream tokenStream(String fieldName, Reader reader){
        TokenStream result = new StandardTokenizer(reader);
        result = new StandardFilter(result);
        result = new LowerCaseFilter(result);
        return result;
    }
   …..
}





2. 문서의 파싱

지난 호에 소개한 간단한 인덱싱 예제(이달의 디스크 SimpleIndex.java) 에서는 단순히 입력받은 문자열을 분석하여 색인화 하는 과정을 거쳤다. 하지만 실전에서는 이와 같은 단순한 문자열 입력 보다는 다양한 문서를 색인화 하는 검색 하는 작업이 더 빈번할 것이다.  XML , PDF, HTML, MS WORD 와 같이 다양한 문서들을 색인화 하기 위해서는 <그림2> 에서와 같이 각각의 문서를 Lucene의 Analyzer가 이해할 수 있도록 해석(parse)해서 텍스트로 추출해 내는 과정이 필요하다. Lucene 패키지 안에도 편의를 제공하기 위한 클래스가 몇몇 존재하기는 하지만 아무래도 외부 서드파티 라이브러리에 의존적일 수 밖에 없다.  이번 연재는 색인화  과정의 이해와 튜닝에 주 초점을 두고 있으니 다양한 라이브러리에 대한 상세한 설명은 생략하기로 한다. 단  아래 <표1>에 이들 라이브러리에 대한 목록과 참고 사이트를   분류해 놓았으니 참고 하도록 하자.













































<그림2> 문서의 색인화 과정






XML

Dom, Sax, JDom,

Piccolo (http://piccolo.sourceforge.net)

Apache Disester (http://jakarta.apache.org/commons/digester/)

PDF

PDFBox (http://www.pdfbox.org), IndexFiles (lucene built-in) ,

LucenePDFDocument (lucene built-in)

Xpdf (http://www.foolabs.com/xpdf)

JPedal (http://www.jpedal.org)

Etymon PJ( http://www.etymon.com)

Html

Jtidy (http://jtidy.sourceforge.net ),
NekoHTML (http://people.apache.org/~andyc/neko/doc/index.html)

HTMLParser( http://htmlparser.sourceforge.net)

MS Word

POI (http://jakarta.apache.org/poi )

Text Extractors( http://textmining.org )

Antiword ( http://www.winfield.demon.nl)

OpenOffice SDK ( http://www.openoffice.org )

<표 1> 문서 파싱을 위한 라이브러리





3. 인덱스 오퍼레이션

지난 연재에서는 간단한 색인화 과정을 거처 인덱스 파일을 생성 시켜 보았었고 이번 단원에서는 이미 생성된 인덱스 파일에서 내용을 추가,수정,삭제 하는 법에 대해 알아보도록 하자. Lucene 에서 인덱스 파일은 바이너리 형태로 존재해 개발 언어나 플랫폼에 구애 받지 않으므로 여러 가지로 상당히 편리하다. 가령 자바 언어로 작성된 서버 프로그램과 윈도우 기반의 클라이언트 프로그램 과의 인덱스 파일을 공유하거나 동기화 작업등을 처리 할 때 상당한 이점이 있을 것이다. <리스트2> AppendIndex.java 예제는 인덱스 파일에 색인을 추가하는 예제 이다. 지난호에 소개한 SimpleIndex.java와의 차이점 이라면 단지 (1) 번 라인에서와 같이 IndexWriter 객체 생성시 생성자의 세번째 인자값에 true 대신 false를 사용한다는 것 뿐이다. True 일때는 인덱스 파일을 새로 생성하거나 덮어 쓰지만 false이면 기존 인덱스 파일에 Document를 추가하게 된다.  그리고 인덱스 파일을 읽거나 수정 또는 삭제 시 편의를 위해 (2)번 라인과 같이 keyword 필드를 추가하였다. 그럼 이제 생성된 인덱스 파일을 이용해 수정과 삭제 처리를 해보자. 수정과 삭제 처리를 위해서는 Lucene 패키지에 포함된 IndexReader(org.apache.lucene.index.IndexReader) 클래스를 사용하는데 delete() 메쏘드 외에도 여러가지 편리하고 직관적인 메쏘드들을 많이 제공하므로 API를 한번쯤 찾아 보는 것도 좋을 것이다. <리스트3>은 삭제 예제인데 (1)번 라인 에서와 같이 검색 Term 객체를 인자로 받아서 삭제하거나 (2)번 라인과 같이 keyword 필드의 id값을 이용해 삭제하는 것도 가능하다.  다만 한가지 기억하고 넘어갈 사항이 있는데 IndexReader 의 delete() 메소드는 Document를 즉시 삭제 하지 않고 '삭제' 상태로 마크 처리했다가 IndexReader 객체의 close() 후 IndexWireter 객체에 의해 인덱스 파일이 merge 된 후에야 완전히 삭제 처리 되므로 이미 수행한 명령을 롤백(undelete() 메쏘드)하거나 최종확정(commit() 메쏘드) 하는 것도 가능하다. '이달의디스크' 에서 전체 소스를 받아 실행 시켜 보면 시스템 로깅을 통해 확인 가능할 것이다. 그리고 인덱스의 업데이트 과정은 이미 소개한 삭제와 추가 예제의 조합이므로 굳이 추가적인 설명을 하지는 않겠다.



<리스트2>  AppendIndex.java
…..
private void index() throws IOException {
Directory dir = FSDirectory.getDirectory("디렉토리경로", true);
Analyzer analyzer=new WhitespaceAnalyzer();
IndexWriter writer = new IndexWriter(dir, analyzer, false); ---------(1)

for (int i = 10; i < 20; i++) {
 Document doc = new Document();
              doc.add(Field.Keyword("id",i+"")); ------------------(2)
 doc.add(Field.Text("title", "title is …"));
 doc.add(Field.Text("content", "content is…."));
 writer.addDocument(doc);
}
writer.optimize();
writer.close();.
}
…..





<리스트3> DeleteIndex.java

private void deleteIndex() throws IOException {
  String dirPath="인덱스 파일 경로";
       IndexReader reader=IndexReader.open(dirPath);

       reader.delete(new Term("title","apache")); --------------(1)
       reader.delete(1); -----------------------(2)
reader.close();
   }
…..




4. 인덱스 튜닝

소위 검색 엔진이라 불리는 편리한 도구의 뒷면 에는 색인화 라는 무시무시한 괴물이 존재하고 있다. 필자의 경우엔 약 8천만건 정도 되는 DB데이터를 Lucene을 이용해 색인화 작업을 한적이 있는데 엄청난 Disk용량은 물론 이거니와 색인화 작업이 완료될 때 까지 걸리는 그 지루하고도 무지막지한 시간을 생각하면 아직도 치가 떨릴 지경이다. 이번 단원 에서는 대용량의 색인화 작업을 위해 알아둬야 할 몇 가지 사항에 대해 소개하고자 한다.  우선 Lucene을 이용한 색인화 작업에서 병목현상이 가장 빈번하게 발생하는 부분은 바로 Disk에 인덱스 파일을 쓰는 작업 일 것 이다. 몇 건 안되는 문서의 색인화 작업시 에는 디폴트 설정을 그대로 사용해서 색인화 해도 별 무리가 없지만 대량의 색인화 작업시 이러한 병목 현상을 줄이기 위해 IndexWriter 클래스는 <표2>와 같이 몇 가지 멤버 변수 설정을 제공한다.  인덱스 튜닝을 위한 첫번째 요소로 mergeFactor 가 있다. mergeFactor는 Disk에 쓰기전 얼마나 많은 문서를 메모리에 저장할 것인가에 대한 요소이며 또한 인덱스 Segment 를 얼마나 자주 병합(merge) 시킬 것인가를 결정 짓는다. 디폴트 값이 10 이므로 별다른 설정을 하지 않으면 1개의 segment를 쓰기 전에 10개의 Document를 메모리에 저장 하게 되며, 10개의 segment를 10배 용량을 가지는 하나의 segment 로 병합 가능하게 한다. 그리고 두번째 요소인 maxMergeDocs 는 한 segment 내에 담을 수 있는 Document 개수를 제한한다. 만약 mergeFactor의 값을 10000으로 하고 maxMergeDocs의 값을 1000으로 한다면 1000개의 Document를 포함 하는 segment 10개가 인덱싱 작업의 결과로 남게 된다.   이처럼 maxMergeDocs 를 크게 설정하면 인덱싱 시간은 줄어 들지만 대신 segment 파일의 개수가 많아 지므로 검색 작업시 불필요한 처리 시간을 소모하게 된다. 따라서 mergeFactor를 크게 잡은 경우엔 IndexWriter의 optimize() 메쏘드를 사용해 여러 segment를 병합시켜 주는 것이 좋다.  마지막으로 minMergeDocs 는 segment에 담길 Document에 대해 버퍼 처리를 얼마나 할지를 결정한다. 기본값이 10이므로 10개의 Document 를 버퍼링 해서 segment에 쓰게 된다. 얼핏 보면 mergeRactor와 비슷한 것 같지만 minMergeDocs 는 RAM 메모리를 사용해 버퍼링할 개수 만을 지정하며 Segment의 사이즈나 처리 개수에 대해서는 관여하지 않는다.
<리스트4> 는 mergeFactor, maxMergeDocs, minMergeDocs를 적용한 IndexTuning 샘플 예제이며 <리스트5>는 출력된 실행결과 이다. 개인 컴퓨터의 사양에 따라 결과가 조금씩 다르겠지만 각 요소의 적용시 minMergeDocs 를 크게 잡아줄 때가 가장 속도가 빠른 것을 확인할수 있을것이다.
java maso.lucene.indexing.IndexTuning mergeFactor maxMergeDocs minMergeDocs 와 같이 실행 가능하며, 예외 처리가 되지 않은 간단한 테스트 용이므로 해당 파라메터를 넘겨주지 않고 실행하면 에러가 발생한다.



변수명

속성 명

디폴트 값

설명

mergeFactor

Ogr.apache.lucene.mergeFactor

10

Segment가 merge되는 빈도수와 사이즈를 컨트롤 한다.

maxMergeDocs

Org.apache.lucene.maxMergeDocs

Integer.MAX_VALUE

하나의 segment에 담길 Document 의 개수를 제한한다.

minMergeDocs

Org.apache.lucene.minMergeDocs

10

색인 처리시 버퍼링에 사용될 RAM의 사이즈를 컨트롤 한다.

<표2> 인덱스 퍼포먼스 튜닝을 위한 요소들



<리스트4> IndexTuning.java

public class IndexTuning {
private void index(int mergeFactor_i, int maxMergeDocs_i, int minMergeDocs_i) throws IOException {
……
 Analyzer analyzer=new WhitespaceAnalyzer();
 IndexWriter writer = new IndexWriter(dir, analyzer,true);

         writer.mergeFactor=mergeFactor_i;
         writer.maxMergeDocs=maxMergeDocs_i;
         writer.minMergeDocs=minMergeDocs_i;
……
System.out.println("소요 시간: "+(endTime-startTime)+" ms");
}

public static void main(String[] args) throws IOException {
 IndexTuning indexTunning = new IndexTuning();
 indexTunning.index(Integer.parseInt(args[0]),Integer.parseInt(args[1]),Integer.parseInt(args[2]));
}
}




<리스트5> IndexTuning.java 실행 결과

mergeFactor = 10
maxMergeDocs = 9999
minMergeDocs = 10
소요 시간: 54407 ms


mergeFactor = 100
maxMergeDocs = 9999
minMergeDocs = 10
소요 시간: 44312 ms


mergeFactor = 10
maxMergeDocs = 9999
minMergeDocs = 100
소요 시간: 9938 ms


mergeFactor = 100
maxMergeDocs = 9999
minMergeDocs = 100
소요 시간: 8453 ms





5. 이슈 및 문제 해결


system lock
Lucene은 기본적으로 read시엔 락이 없지만 create,update,delete 시엔 항상 시스템 락을 건다 ( /temp/lucene-xxxxx.lock )  일련의 작업이 끝난후 IndexWriter가 close 될때 락을 해제하게 되는데 제대로 close 되지 않았거나 중간에 에러가 발생할 경우엔 Rock 이 해제되지 않은 채로 존재하기 때문에 옵티마이징이나  기타 다른 작업을 할수가 없다. 따라서 이때는 해당 락을 직접 삭제해줘야 한다.
(예외 : java.io.IOException: lock obtain timed out  ...  C:\temp\lucene-xxxxxxxxxxxx.lock )

최대 파일 열기 개수 초과 오류 ( Too many open files Exception )
Lucene을 이용해 검색 작업을 하다 보면 종종 '최대 파일 열기 개수 초과' 라는 예외상황이 발생하곤 한다. 이때 가장 먼저 체크 해봐야 할 곳은 인덱스 파일이 생성된 디렉토리 이다. 인덱스 파일이 수많은 segment 로 구성되어 있다면 IndexWriter 의 optimize() 를 이용해서 하나의 segment로 수정해줘야 한다. 인덱스 옵티마이징 이후에도 똑 같은 에러가 계속해서 발생 된다면 인덱스 파일을 읽어 들이는 IndexReader 객체가 사용 후 제대로 반환 되는지 체크해 볼 필요가 있다. Lucene의 IndexReader 는 쓰레드에 안전 하므로 풀링 기법 을 사용하거나 싱글톤 패턴을 적용시켜 사용하는 것이 좋다.
이런 저런 문제도 아닌 경우엔 OS에서 허용 가능한 파일 오픈 개수를 늘려줘야 한다.


비 영어권 문자의 검색
한글,중국어,일어 와 같은 아시아권 문자의 검색을 위해서는 utf-8 인코딩을 사용하면 비교적 간단 하게 해결 되지만 형태소 검색과 같은 기능은 구현하기 힘들게 된다. 이때는 lucene 의 sandBox에 포함된 CJK Analyzer를 사용하면 되는데 아래 링크에서 다운로드 받을 수 있다.
http://svn.apache.org/repos/asf/lucene/java/trunk/contrib/analyzers/src/java/org/apache/lucene/analysis/cjk/
CJKAnalyzer 적용에 관한 보다 상세한 자료는 윤용현님의 사이트인 jazzzvm.com 을 참고하자. ( http://www.jazzvm.net/board/view.jazz?code=864593&seq=827&currentPage=1&currentBlock=1 )
덧붙여 CJKAnalyzer는 내부적으로 StopFilter를 사용하므로 다운로드 받은 CJKAnalyzer 의 멤버변수인 String[] STOP_WORDS 에 기본적인 불용어(StopWord)를 추가 해서 사용하는 것도 괜찮을 것이다.





6. Query Syntax (http://lucene.apache.org/java/docs/queryparsersyntax.html)


기본쿼리
tiele 필드와 text 필드에서 AND 검색을 하려면 아래 (1)과 같이 [필드명:검색어 AND 필드명:검색어] 와 같은 질의를 사용 가능하다. 디폴트 필드가 text 일 경우  (2)번 질의와 같이 text 필드를 생략 할 수 있다.
title:"The Right Way" AND text:go  -----------(1)
title:"Do it right" AND right ------------(2)


와일드카드 검색
? : 단일 문자 와일드카드
* : 다수 문자 와일드카드
와일드 카드에 사용되는 기호는 검색어의 중간이나 끝에 위치 가능하며 검색어의 시작 단어로는 사용할 수 없다.


Fuzzy 검색
roam~ 과 같은 검색 키워드를 사용하면  foam, roams와 같은 유사 검색을 한다.


Proximity 검색
한 문서 안에서 각각 10 단어 내에서 "apache"와 "jakarta"를 검색하려 한다면 "jakarta apache"~10과 같은 형태로 검색을 수행한다.


Range 검색
mod_date:[20020101 TO 20030101]
20020101과 20030101을 포함하여, 이 범위 안에 있는 값들을 가진 mod_date 필드들을 포함하고 있는 문서를 검색한다.
title:{Aida TO Carmen}
이것은 Aida와 Carmen 범위 내에 있는 title을 갖는 문서들을 찾는다. 이 때, Aida와 Carmen은 포함되지 않는다.

[] : 최소값과 최대값을 포함한 Range 검색
{} : 포함하지 않는 Range 검색


Boosting a Term
Lucene은 발견된 term들을 기반으로 문서가 일치하는 정도를 판단하는 기능을 제공한다. term을 boost하려면, 검색하려는 term의 끝에 boost factor (숫자)와 함께 캐럿 기호 "^"를 사용한다. boost factor가 높을수록, term과의 관련성이 더 높아진다.
jakarta ^4 apache :  Jakarta 에 boost 적용


Boolean 연산자
연산자들은 반드시 대문자여야 한다.

OR : 디폴트 결합 연산자.  기호 || 를 사용할 수도 있다.
AND :  교집합 연산자. 기호 &&를 사용할 수도 있다.
+ : 반드시 포함되어 있어야 하는 Term을 지정한다.  (ex: +jakarta apache) NOT : 차집합 연산자. ! 기호를 사용할 수 있다.
- : 이 연산자는 "-" 기호 다음에 있는 term을 포함하고 있는 문서들은 제외한다. "jakarta apache"는 포함하지만 "jakarta lucene"은 포함하지 않는 문서들을 검색하려면 "jakarta apache" -"jakarta lucene"과 같은 형태의 질의를 사용하면 된다.


Escaping Special Characters
아래와 같은 특수 문자를 escaping하기 위해 문자 얖에 역슬래쉬(\) 문자를 사용한다.
+ - && || ! ( ) { } [ ] ^ " ~ * ? : \
즉 (1+1):2를 검색하려면 \(1\+1\)\:2와 같은 질의를 사용한다.





예제파일 다운로드----> http://www.jongsclub.com/pds/lucene_2_src.zip


http://jongsclub.com/webedit/studyView.jsp?num=27