소스의 중복도를 체크하는 프로그램의 파일럿을 만들어보려고 새벽 출근한김에 뚝딱거려보았다.


뭐 기본적으로는 루씬으로 색인 후 루씬의 TermFreqVector를 이용해서 코사인유사도를 사용해서 일단 뽑아보는거였는데.. lucene 3.6까지는 TermFreqVector가 있었는데 4.0에서는 그것이 Terms, TermEnum, DocEnum등으로 대체가 된듯 하였다.


그래서 관련한 자료들을 찾아보고

테스트 코드를 만들어가면서 이것저것 실행을 해보고 있었는데

Terms.getSumTotalTermFreq()의 결과가 영~~ 이상하게 나온다. 계속 -1이 나오는것..


IndexReader로부터 하나의 Document에 대한 Terms 인스턴스를 얻어내면

그 Terms의 인스턴스는 딱 하나의 Document가 색인되어있는 역인덱스 파일의 데이터와 같은

형태를 가진다.


예를 들어서,

> Document 1 : "learning perl learning java learning ruby"
> Document 2 : "perl test"


이 두개의 Document가 있을 경우 IndexReader로부터

아래와 같은 코드로 Terms 인스턴스를 얻어낸다..


> IndexReader ir = IndexReader.open(dir);
> Terms terms = ir.getTermVector(0, "f");
>
> System.out.println(terms.getDocCount()); -> 1
> System.out.println(terms.getSumDocFreq()); -> 4
> System.out.println(terms.getSumTotalTermFreq()); -> -1


 옆에 화살표에 표시된 숫자가 결과값인데.. 앞에 이야기하였듯이 terms 인스턴스는 단일 Document가 색인된 역인덱스 파일의 데이터와 같다. 따라서 term이 위와 같이 데이터가 나타나는듯하다.

terms로부터 TermsEnum을 얻고, 이 enum으로부터 각 term에 대한 통계정보를 받을 수 있는 구조이며

위 나열되어있는 terms의 메서드들은 대부분 TermsEnum으로 얻어지는 각 term들의 통계정보에 대한 합이다.


예를들어 getSumDocFreq는 TermsEnum.docFreq()의 합인데, TermsEnum.docFreq()는 

"How many documents contain at least one occurrence of the term in the field" 이다.

즉, 각 term을 가진 Document의 수인데, 단일 Document가 색인된 역인덱스파일에 대한 형태이므로 learning - 1, perl - 1, java - 1, ruby - 1이 되고 이 합인 4가 나오는 형태이다.


물론 이것을 전체 색인 파일을 대상으로 Terms 인스턴스를 뽑아내면 결과는 달라질것이다.

일단 perl에 대한 docFreq()가 1이 아니라 2가 될테니..


아무튼.. -1이 나오는 것이 이상하여..

루씬 메일링 그룹에 질문을 올렸다.. 아 근데 역시 영어가 문제야..ㅠㅠ 

영어로 메일쓰기가 너무 어렵고 보내기 버튼을 누르는 순간까지도 이걸 이 사람들이 질문을 이해할까? 이런 고민만..-.-;;


근데 이 궁금증을 해결할 방법이 없어서 질문을 보냈는데, 루씬 메인 커미터중 한사람인 Michael McCandless로 부터 답을 받았다. 결론은....  현재 색인 포맷이 각 Document별로 저 데이터를 가지고 

있지 않아서 그런것이며 원칙적으로는 수정을 하려고 하고있다.

현재 상태로 -1은 맞는 값이다. 라는 것..


이게 javadoc에 codec이 통계 데이터를 가지고 있지 않으면 -1이 나온다라고 되어있었던것 같은데..

그게 이런 의미로 연결되는 줄은 전혀 몰랐다.. 역시 일단 막 던지고 봐야돼 -_-;


급한거면 지라 이슈를 열어서 패치를 해주겠다고 다시 물어봐오는데..

그럴필요 없다라고.. 다시 메일을 써야하는건 함정 --;


결국 새벽에 만들던 파일럿 프로그램은 3.6을 사용해서 원래 알던 방법으로 구현을 해봤는데..

결과가 영 이상하다..ㅎㅎ 생각해보니 중복 소스를 체크하는 것은 TF-IDF를 이용한 유사도 점수와는 좀 다른 문제인듯하다.



Posted by 용식

댓글을 달아 주세요

회사에서 개발해서 사용 중인 

모니터링 시스템 관련 디버깅하다가

잠깐 짤막한 정리



IndexWriter는 하나로 유지하면서 close 없이 Incremental indexing을 계속 실행하고

IndexSearcher는 IndexWriter로부터 IndexReader를 받아와서 새로 생성하면

변경된 사항을 IndexWriter의 commit 여부 상관없이 적용된 Document의 수정 사항을

가져 올 수 있다.


다만 매번 IndexSearcher를 생성하는 것은 부담이 되기 때문에

IndexReader를 체크하여 changed여부를 확인한다.


이러한 롤을 가지고, Searcher Pool을 운용해도 좋을 것 같다는 생각이 들기도 하고...


Document수가 많지 않으면

그냥 매번 생성하는 것도 나쁘지 않을 것 같고..


IndexWriter가 Incremental indexing의 종료 이벤트를 던져주면

그것으로 Searcher를 새로 생성해도 될 것 같은데..


Incremental indexing 의 주기와 건수

Searcher가 실시간으로 적용 사항을 반영해야 하는 이슈가 중요한지등에 따라서

여러가지 방안이 나올 수 있을 듯...


Posted by 용식
TAG 루씬

댓글을 달아 주세요

NearRealTimeSearch를 구현하는데 사용되던 메서드인
IndexReader reopen 메서드가 deprecated되고
static openIfChanged(IndexReader ir) 메서드로 대체 되었습니다.

  IndexReader newReader = IndexReader.openIfChanged(oldReader); 

oldReader가 변경되었다면 새로운 Reader를 반환하고
아니라면 null을 반환합니다.

이때 oldReader와 동일한 타입이 넘어옵니다.

MultiReader라면 MultiReader가 넘어오게 됩니다.


Posted by 용식

댓글을 달아 주세요

댓글로 질문을 하신분이 계셔서 그 내용을 확인하고 답변을 드리기 위해서
포스트를 작성합니다.

질문하신 내용은
Custom Analyzer를 만들어서 사용하는데
실제로 원하는대로 작동을 하지 않는다는 것이었습니다. Custom Analyzer의 로직은 아래와 같습니다.



즉, 사용자에게 입력받은 키워드를 별도로 준비된 형태소분석기를 통해서 명사를 추출하고
이 추출된 명사의 위치정보와 기타 루씬의 Filter를 적용하기 위하여 이를 다시 StandardAnalyzer로 분석하는
CustomAnalyzer였습니다.

그런데 테스트를 해보면 
처음 "무궁화 꽃이 피었습니다." 라고 실행하면 정상적으로 "무궁화" , "꽃"이 추출되어 나오는데
그 다음부터는 "무궁화", "꽃이", "피었습니다." 라고 입력된 키워드가 마치 그냥 StandardAnalyzer로 분석되는 것처럼
나온다는 것에 대한 문의 였습니다.

테스트 코드는 아래와 같습니다.


똑같은 문장 "무궁화 꽃이 피었습니다"를 3번 돌면서 분석하는 로직입니다.

보시면 Analyzer를 한번만 생성하여 loop를 돌면서 analyzer.reusableTokenStream을 사용하고 있는 것이 보입니다.

여기서 만약 Analyzer를  loop 돌때마다 new로 생성하여 사용하면 결과는 3번 모두 "무궁화", "꽃"이 분석되어 나옵니다.

문제는 reuseableTokenStream에 있는데요.. 메서드명에서 알 수 있듯이 이는 TokenStream을 재사용하는 메서드입니다.
이 메서드를 실행하게 되면 아래의 메서드가 실행됩니다.


getPreviousTokenStream에 의해서 이전에 저장된 TokenStream을 가져오고 그것이 있으면 그 Stream을 그대로 사용하도록 되어있습니다. 그 TokeStream에 사용자가 입력한 키워드를 주고 분석을 하게 되는 것 입니다.


여기서getPreviouseTokenStream에 의해서 return되는 TokenStream이 바로 CustomAnalyzer에서 만들어내고 있는
 

final Tokenizer source = new StandardTokenizer(Version.LUCENE_33, reader);

Set<String> stopSet = new TreeSet<String>();

return new TokenStreamComponents(source, new StopFilter(Version.LUCENE_33, source, stopSet)); 


이것입니다. 

즉, 첫번째 분석요청에 대해서는 형태소분석을 한 결과를 가지고 위의 TokenStream을 만들어서 적용한 결과가 정상적으로 나왔는데 그 이후의 분석 요청부터는 이미 만들어진 TokenStream이 있기 때문에 CustomAnalyzer에서 형태소분석을 요청하는 부분이 적용되지 않고 사용자가 입력한 키워드를 곧바로 위 StandardAnalyzer를 기본으로 한 TokenStream에 적용해버리기 때문입니다.

TokenStream 자체에 대한 재사용이라고 생각하시면 좋을 것 같습니다.

이를 피하는 방법은..

1. 위에서 언급된 것 처럼 new로 매번 Analyzer의 생성
2. Analyzer.reuseableTokenStream을 사용하지말고 Analyzer.tokeStream 메서드를 사용
3. TokenStream을 재사용하시려면, 실제 형태소분석을 하고 그것을 바탕으로 위치 정보를 뽑아 낼 수 있도록 
형태소분석을 전용으로 하는 Filter를 만드시는 방법

등이 있을 것 같습니다.

위와 같은 경우 "무궁화"와 "꽃"이 정상적으로 추출되는 경우라도 실제 원문과 비교하여 
위치정보등이 틀릴 수도 있습니다. 왜냐하면 위치정보를 추출한 원문이 "무궁화 꽃이 피었습니다." 가 아니라
"무궁화 꽃" 이기 때문입니다. 우연찮게 명사가 같은 위치에 있어서 결과가 제대로 나오는 것 같지만 보통의 경우에는
위치정보를 제대로 사용하실 수 없을 것입니다.

이런저런 이유로 3번이 가장 명확한 방법으로 보이네요.



Posted by 용식
TAG 루씬, 질문

댓글을 달아 주세요

  1. Danny 2011.12.27 23:59  댓글주소  수정/삭제  댓글쓰기

    알려주신대로 우선 두번째 방법 TokenStream을 재사용하지 않게해서 해결했습니다. 도움을 주셔서 정말 감사합니다^^

이번에는 많은 분들께서 기다리셨을(?) 명사 추출 필터입니다.
사전에 등록되어있는 명사를 기반으로 Tokenizer나 선행 TokenFilter로 부터 넘어온
Token을 탐색하여 명사를 추출해내는 구조입니다.

기대를 하셨다면 죄송스럽게도 굉장히 별거 없는 Filter입니다.
 
형태소 분석이 아닌 단순히 Token을 읽어 사전에 있는 단어를 추출하는 방식입니다.
Token탐색도 그냥 character단위로 쭉.... 합니다.

이걸 다른 자료구조형으로 만들면 속도를 빠르게 할 수도 있겠지만
여기서는 코드의 간결함을 위해서 복잡한 부분은 모두 제외를 시킬 예정입니다.

이 추출의 과정이 형태소분석을 기반으로 알고리즘이 만들어져 있다면 이 Filter가 형태소분석기가 됩니다.
하지만 이 형태소분석이라는 것이 쉬운 것이 아니고..요즘은 웹이 처음 태동 할 당시처럼 형태소 분석으로
명사만 추출해서 되는 상황도 아닙니다. 때문에, 여기서는 사전을 기반으로 Token을 탐색하여
매칭되는 명사들을 그냥 추출해는 정도로 구현을 하려고 합니다.

그리고 가장 중요한 것이 사전으로 형태소분석을 하던 지금과 같은 방식을 사용하던 이 사전은 계속 유지보수가 되어야합니다.
신조어가 계속 생겨나기 때문입니다.

때문에 기본이 되는 사전을 가지고 핵심적인 모듈을 구현하고 그외 신조어/불용어/동의어등 
사용자가 직접 커스터마이즈 할 수 있는 사전을 제공하여 그 rule을 추가적으로 적용하는 방법들도 많이 사용됩니다.

그럼 이 Filter의 테스트케이스부터 보겠습니다.

 

DevysNounFilterTest.java

우선 추출의 대상이 되는 문장이 있고 여기서 추출되어야 하는 단어들을 미리 List에 넣어두고
추출되는 단어들을 비교합니다. 동의어필터 테스트케이스와 마찬가지로 양방향으로 검증을 합니다.

명사가 추출되는 것이 보이시나요?

비록 아주 단순한 로직이지만 이렇게 문장으로부터 원하는 키워드가 추출되기 시작하면 뭔가 기분이 좋아집니다. :)

그럼 Filter의 코드를 보겠습니다.

 
DevysNounEngine.java
DevysNounFilter.java

기본적으로는 동의어필터의 SynonymEngine과 같은 인터페이스를 구현하고있습니다.
하나의 Token으로부터 여러개의 Token이 파생되는 경우에는 대부분 Stack<AttributeSource>로 활용이
가능하기 때문에 이를 이용해서 인터페이스를 만들고 이용합니다.

내부로직은 각 엔진이 가지고 있게 됩니다.

Token을 하나씩 인덱스를 증가시켜가며 단어를 추출하고
이를 사전과 비교하여 사전에 있는 단어라면 이를 Stack에 저장해 넘겨줍니다.

여기서 word 타입만을 명사 추출 대상으로 삼는데
동의어의 경우 기본적으로 명사를 추출 할 대상이 아니게 되고 기타 불필요한 로직을 타지 않게 하기 위합니다.
따라서 동의어필터와 명사필터의 우선순위를 봤을 때 추출된 명사의 동의어를
추출해야하므로 명사필터가 동의어필터보다 앞에 와야함을 알 수 있습니다.

사전/탐색/비교등의 로직을 더 빠르고 확실하게 구현한다면 그래도 꽤 쓸만한 명사 추출 필터가 되지 않을까 합니다.
다만 여러방법으로 명사를 추출하도록 하는 것 예를들면 사전과 매칭되는 가장 긴 명사를 추출한다던가 하는 부분이 추가되면
더 좋을 것 같습니다.

이 필터에서 한가지 중요한 것은 원문 토큰도 리턴을 해준다는 것 입니다.

즉 , "하둡을" 이라는 Token에서 "하둡"뿐만이 아니라 
"하둡"+"하둡을"Token이 추출되는 것 입니다.

이는 나중에 사용자가 "하둡을 사용하는 방법"이라고 검색을 했다고 하면
기본적으로 사용자가 입력한 키워드에 더 많은 가중치를 두어서
검색 결과의 우선순위를 높이는데 사용하기 위함입니다.

"하둡을" * BOOST! + "하둡" + "사용하는" + "방법" 와 같은 형태의 쿼리를 사용 할 수도 있을 것 입니다.

또한 위 로직에서는 빠져있지만 추출된 명사의 OFFSET을 제대로 계산하여
Attribute에 설정해주어야 합니다. 나중에 매치된 키워드에 하이라이팅을 해주기 위해서는
OFFSET정보가 필요하기 때문입니다.

이렇게해서 루씬을 사용한 개발에서 가장 중요하고 어려운 부분 중 하나인
Analyzer에 대한 이야기를 우선 마칠 수 있을 것 같습니다. 어렵고 중요한 부분인데 설명이나 코드의 부족함이
많았던 것 같아서 아쉬움이 남기도 하고 걱정도 됩니다.

공유해드린 소스를 사용하여 이런저런 테스트를 해보신다면
더 쉽게 Analyzer에 접근 하실 수 있을 것이라 생각됩니다.

이후에는 색인과 검색, 그리고 쿼리등에 대해서 간단한 코드를 사용하여
설명을 드리려고 합니다.

아직 코드를 작성해두지 않아 조금 시간이 걸릴지도 모르겠네요..^^
 
https://github.com/need4spd/aboutLucene
에서 체크아웃 받으 실 수 있습니다. 


Posted by 용식

댓글을 달아 주세요

  1. lee6boy 2011.09.28 00:19  댓글주소  수정/삭제  댓글쓰기

    교수님이 갑자기 "루씬가지고 한글 어떡게 처리할지 알아보아라"라고 과제 주셔서 막막했는데 포스트 하신것 보구 많이 배웠습니다. 감사합니다ㅋ

    • 용식 2011.09.28 00:59 신고  댓글주소  수정/삭제

      도움이 되셨다니 다행입니다.
      여러 종류의 필터를 만들어 데코레이터 패턴으로 붙여 사용하시다 보면 아마 생각하셔야 할 부분이 더 많이 나올거에요. 그리고 사전을 사용하여 위와 같이 Scan을 하는 방식에서는 한글자로 된 단어들의 추출에서 가장 정확도가 떨어지는 편입니다.

      이 부분은 쿼리의 가중치로 해결을 해 나가야겠죠. :)

      좋은 결과 있으시길 바라겠습니다.

  2. Danny 2011.12.20 16:44  댓글주소  수정/삭제  댓글쓰기

    /*
    저는 원격 소켓통신으로 한글형태소분석기를 이용할 수 있는 것이 있어서
    Lucene 분석기의 CJKAnalyzer.java 파일을 수정하여 ElasticSearch 에서 사용하려고 하는데 잘 안되어 용식님의 도움 부탁드립니다.

    아래와 같이 하면 처음 한번은 제대로 된 결과를 가져옵니다. (무궁화 꽃이 피었습니다 -> 무궁화 / 꽃)
    그런데 두번째 부터는 제대로 안나오고 공백분리되어 나옵니다. (무궁화 꽃이 피었습니다 -> 무궁화 / 꽃이 / 피었습니다)
    인덱스를 닫고 다시 열거나 몇분후에 다시 시도하면 다시 정상 결과가 한번 나옵니다.
    kmatestOrg.txt 파일을 보면 처음 한번만 또는 한참후에 재시도 또는 인덱스 다시 열었을때만 로그가 찍힙니다.
    kmatestOrg.txt 까지 제대로 들어 온것은 kmatestResult.txt 까지 잘 나오고 정상적으로 결과가 리턴되는 것을 보면 소켓통신은 잘되는 것 같은데요.

    혹시 reader 를 문자열 바꾸는 과정이 잘못되어서 그런 건가요? 제가 자바와 루씬을 처음 해보는 것이라서요...
    도움 부탁드립니다.

    reader -> string -> (log) -> socket korean analyzer -> result string -> (log) -> reader -> tokenizer -> stopfilter -> return

    */

    // CJKAnalyzer.java 하단 원본
    /*
    @Override
    protected TokenStreamComponents createComponents(String fieldName,
    Reader reader) {
    final Tokenizer source = new CJKTokenizer(reader);
    return new TokenStreamComponents(source, new StopFilter(matchVersion, source, stopwords));
    }
    */


    // 소켓통신 형태소분석방식으로 수정한 것
    @Override
    protected TokenStreamComponents createComponents(String fieldName, Reader reader) {


    // 입력받은 reader를 문자열로
    String sourceStr = new String();
    char[] arr = new char[8*1024]; // 8K at a time
    StringBuffer buf = new StringBuffer();
    int numChars;
    try {
    while ((numChars = reader.read(arr, 0, arr.length)) > 0) {
    buf.append(arr, 0, numChars);
    }
    sourceStr = buf.toString();
    }
    catch(Exception e){
    e.printStackTrace();
    } finally {
    //
    }


    // 원본을 로그 파일에 저장
    try {
    BufferedWriter out1 = new BufferedWriter(new FileWriter("/home/search/kmatestOrg.txt", true));
    out1.write(sourceStr); out1.newLine();
    out1.close();
    }
    catch(Exception e){
    e.printStackTrace();
    } finally {
    //
    }


    // 소켓통신 형태소분석
    Socket socketK = null;
    BufferedReader readerK = null;
    BufferedWriter writerK = null;

    try {
    String IP = "123.123.123.123"; // Server IP
    int port=Integer.parseInt("1234"); // Port Number
    socketK = new Socket(IP, port); // 지정된 IP와 port를 이용하여 Socket Object 생성
    readerK = new BufferedReader(new InputStreamReader(socketK.getInputStream()));
    writerK = new BufferedWriter(new OutputStreamWriter(socketK.getOutputStream()));
    writerK.write(sourceStr);
    writerK.newLine();
    writerK.flush();

    String result;
    result = readerK.readLine();

    // reader 값 형태소분석결과로 재정의
    reader = new StringReader(result);

    // 결과를 로그 파일에 저장
    BufferedWriter out2 = new BufferedWriter(new FileWriter("/home/search/kmatestResult.txt", true));
    out2.write(result); out2.newLine();
    out2.close();
    }
    catch(Exception e){
    e.printStackTrace();
    } finally {
    if(readerK != null) try { readerK.close(); } catch(Exception e) {}
    if(writerK != null) try { writerK.close(); } catch(Exception e) {}
    if(socketK != null) try { socketK.close(); } catch(Exception e) {}
    }


    // TokenStreamComponents 만들기 위해 StandardTokenizer 로 토크나이징 (공백구분자로 단순 토크나이징)
    // final Tokenizer source = new CJKTokenizer(reader);
    final Tokenizer source = new StandardTokenizer(matchVersion, reader);

    // StopFilter 적용후 결과 리턴
    return new TokenStreamComponents(source, new StopFilter(matchVersion, source, stopwords));
    }

    • 용식 2011.12.20 18:18 신고  댓글주소  수정/삭제

      안녕하세요 제가 ElasticSearch라던가 테스트로 사용하신 문서들의 내용을 몰라 정확한 답변은 어렵지만 분석된 결과를 봐서는 Analyzer가 다르게 사용 된 것 같습니다.

      댓글에 붙여주신 소스 맨 마지막 부분이 ..
      // final Tokenizer source = new CJKTokenizer(reader);
      final Tokenizer source = new StandardTokenizer(matchVersion, reader);

      이렇게 되어있는데요 StandardAnalyzer를
      사용하시면 안되는 것 아닌가요?

      말씀하신 형태소분석이라는게 어떻게 되는 구조인지도 몰라서.. 명확하게 답변을 못 드리겠네요~

  3. Danny 2011.12.21 00:23  댓글주소  수정/삭제  댓글쓰기

    안녕하세요. ElasticSearch는 Lucene 위에 올라가는 RESTful API입니다. Solr 비슷한겁니다. 분산/클라우드환경에서 JSON 포맷으로 명령을 HTTP 를 통해 전달하며 색인/검색 등 대부분의 Lucene 기능을 JAVA로 프로그래밍하지 않고도 검색엔진 운영이 가능합니다. 저는 ElasticSearch 위에 Elastica php client 로 검색기능을 구현하고 있는데 형태소분석기부분은 그래도 JAVA로 해야하는데 자바로 첨 해보는 것이라서요...

    위 소켓으로 연결하는 형태소분석기 서버는 기존에 사용하던 것인데 예를 들어
    "자바로 검색엔진 개발하기"라고 입력하면 "자바 검색 엔진 개발" 이라고 결과가 나옵니다.
    이 결과를 다시 StandardTokenizer로 처리한 것은 이미 형태소분석은 했으므로 위치정보 등을 처리하기 위해
    단순히 공백으로만 구분한후 stop word 제거하고 TokenStream 결과를 받기 위해서입니다.

    처음 한번은 결과가 제대로 나오고 두번째부터 reader로 입력되는 것을 제대로 받지 못하고 있습니다.
    reader 를 재사용하고 reset 하는 ReusableAnalyzerBase.java 부분과 관련이 있는 것 같기도 한데
    제가 자바를 잘 몰라서 CJKAnalyzer를 후딱 바꿔치기해서 사용하려고 했는데 잘 안되네요...
    용식님처럼 천천히 확실히 공부해야겠네요... 감사합니다.

    • 용식 2011.12.21 09:46 신고  댓글주소  수정/삭제

      아... 이제 댓글에 달아주신 코드가 이해가 좀 되네요.
      조금 더 살펴보고 혹시 제가 발견한 내용이 있으면 다시 댓글 달아드리겠습니다.

      그런데 혹시 위 코드에서 원격으로 형태소 분석을 받아온 결과를 로깅해보면 어떤 결과가 나오는지 알 수 있을까요?

      "String result;
      result = readerK.readLine();"

      이 부분이요...

      첫번째, 두번째 모두 "무궁화 / 꽃"이 나오나요?

      그리고 사용중이신 루씬의 버전이 어떻게 되시나요?

    • 용식 2011.12.21 13:59 신고  댓글주소  수정/삭제

      간단하게 테스트를 해봤을 때와 보내주신 내용을 종합했을 때 형태소분석이 안된 문장을 StandardAnalyzer가 분석한 결과로 보이거든요...

      @Override
      protected TokenStreamComponents createComponents(final String fieldName,
      Reader reader) {

      //이렇게 형태소 분석이 되었다고 가정
      String result = "무궁화 꽃";
      reader = new StringReader(result);

      final Tokenizer source = new StandardTokenizer(Version.LUCENE_33, reader);

      Set<String> stopSet = new TreeSet<String>();

      return new TokenStreamComponents(source, new StopFilter(Version.LUCENE_33, source, stopSet));
      }

      로 간단히 테스트를 해봤을 때는 입력값과 상관없이
      정상적인 결과가 나오거든요...

      형태소분석이 된 결과값의 문제이거나..
      아니면, Analyzer를 호출하는 코드가 제가 테스트 한 방법과 다를 수 있을 것 같습니다. 로깅부분과 테스트 하신 클라이언트 코드를 보여주시면 좀 더 도움이 될 것 같아요.

  4. Bongs 2012.07.25 16:42  댓글주소  수정/삭제  댓글쓰기

    안녕하세요. 루씬 포스트 잘보고 있습니다.
    최근 루씬 만질일이 생겨서 만지는 중에 analyzer 에서 문제가 발생했네요 ㅠㅠ

    lucene in action 2nd 와 포스트를 번갈아 보면서 형태소 분석기(TermExtractor: 명사추출)에 해당하는 모듈을 붙였습니다.

    전부 원하는대로 잘 동작하는데, 한가지 문제점이 발생했네요.

    뭐냐면, 추출한 텀과 함께 원본 문자열도 같이 분석결과로 배출됩니다.
    이거 어떻게 안나오게 할 수 있는 방법이 없나요??

    예를 들어, '대한민국에서' 라는 토큰을 분석하면 --> '대한민국', '대한민국에서' 가 같이 추출됩니다.
    '대한민국' 은 제가 restoreState() 를 통해서 넣은것인데, '대한민국에서' 는 자동으로 분석결과에 추가되는 군여.

    Tokenizer 는 WhitespaceTokenizer() 를 사용했습니다.

    analyzer 는 아래와 같습니다.

    protected TokenStreamComponents createComponents(String fieldName,
    Reader reader) {
    // TODO Auto-generated method stub
    WhitespaceTokenizer wt = new WhitespaceTokenizer(Version.LUCENE_36,reader);
    return new TokenStreamComponents(wt, new BongsTokenFilter(wt));
    }

    원본 문자열이 분석되지 않게 하는 방법은 없을까요? ㅠㅠ

    • Bongs 2012.07.25 17:50  댓글주소  수정/삭제

      해결은 했는데, 뒤에 stopfilter 를 하나 더걸어서 첫번째 인덱스는 삭제하는 식의 로직을 썼는데, 이방법밖에 없는가 싶네요 ...

    • 용식 2012.07.25 23:41 신고  댓글주소  수정/삭제

      BongsTokenFilter가 어떻게 구현되어있는지 모르겠습니다. 혹시 제가 작성한 소스를 참고로 하셨다면 원문 토큰이 같이 나오도록 되어있을거에요.

      제거하시기 위해서는..
      StopFilter가 아니라 Filter를 조금만 수정해주시면 되는데요...

      아마 WhitespaceTokenizer에서 넘어온 TokenStream을 그대로 한번 저장하고.. 이 Token에 대해서 Filter를 적용하여 추출된 Token을 저장하는 로직으로 구현이 되어있을듯 합니다.

      WhitespaceTokenizer에서 넘어온 TokenStream을 처리하는 부분을 한번 잘 살펴보세요~

    • Bongs 2012.07.26 11:26  댓글주소  수정/삭제

      incrementToken() 함수를 아래처럼 바꾸니까 되네요 .. 간략하게 적어보면 .. (dev 용식님 코드 참조해서 만들었어요)

      clearAttributes();

      if (stState.size() > 0) {
      State state = stState.pop();
      restoreState(state);
      return true;
      }

      if (input.incrementToken()) {
      BongsAnalyzer.analyze(input.cloneAttributes(), stState);
      // positionAtt.setPositionIncrement(positionAtt.getPositionIncrement() + 1);
      if (stState.size() > 0) {
      restoreState(stState.pop());
      }
      } else {
      return false;
      }

      return true;


      제가 restoreState() 에 대해서 헷갈렸나보네요 ...

      매번 incrementToken() 시에 restoreState() 를 통해서 해당 TokenStream 에 상태를 변경/저장하는 역할인가보군여 ...

    • 용식 2012.07.26 14:04 신고  댓글주소  수정/삭제

      처리가 되셔서 다행이네요..^^
      원본토큰을 색인시에 같이 추출했던 이유는
      사용자가 입력한 그 문장 자체에 가중치를 더 주고 검색을 하기 위함이었습니다.

      "자바 개발자를 위한 오브젝티브C" 라는 검색어를 사용자가 입력했을 때 명사만 추출하여 "자바" + "개발자" + "오브젝티브C" 로 검색하는 것 보다 원문을 그대로 사용하여 나오는 것들이 더 명확할 것이라고 생각을 했었거든요.

      물론 이 부분은 비지니스에 따라서 많은 편차가 있을 것 입니다. ^^

    • Bongs 2012.07.26 20:37  댓글주소  수정/삭제

      네. 감사합니다. 많은 도움이 됬네요.

      근데, 루씬 색인/검색 속도가 어마어마 하네요 ..ㅎㅎ


      색인 속도는 멀티쓰레드로 ramdirectory 와 fsdirectory 를 사용했으니 그렇다치고, filter sort group 검색속도도 ㄷㄷㄷㄷ 입니다 ㅋㅋ

      문서건수는 100만건정도, 필드 10여개 넘고 기타 설정 잡고, 피씨에서 돌렸는데도 오픈소스가 이정도라면 .... ㅎㅎㅎ

      근데, 혹시 루씬 3.6에서 mergeFactor, minMergeDocs 설정 방법 혹시 아시나요? ^^

      좋은밤 되세요 ^^

    • 용식 2012.07.30 11:19 신고  댓글주소  수정/삭제

      100만건 정도라면 아마 거의 속도의 저하를 못 느끼실거에요~
      정렬이나 group 같은 경우는 색인된 전체 문서 건수 보다는 검색되어 나오는 문서 건수에 영향이 더 크기도 하구요~

      문서를 막 늘려보세요~ ㅎㅎ

      그리고 mergeFactor, minMergDocs에 대해서는
      저도 최적화된 설정법은 잘 모르고 있습니다.^^

  5. 2013.11.22 07:47  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

    • 용식 2013.11.22 16:59 신고  댓글주소  수정/삭제

      안녕하세요?
      말씀하신대로 토큰을 분리하려면 하나의 Filter만으로는 어렵습니다.
      Tokenizer 레벨에서부터 작업이 들어가야 할 것 같네요..
      질문하신 부분에 대해서 원하시는 답변을 못드려 죄송합니다..^^;

      물어보시는 내용에 대한 설명을 드리려면 너무 범위가 커서 여기서는 답변을 드리기가 어렵네요... :)

  6. 2013.11.22 17:21  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  7. 2013.11.22 17:21  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  8. bob 2013.11.24 16:03  댓글주소  수정/삭제  댓글쓰기

    위에 예제 어널라이져로 만들어서 인덱싱하고 싶은데 토크나이져쪽에서
    이셉션 나네요 ㅜㅜ

  9. 123 2013.11.24 16:43  댓글주소  수정/삭제  댓글쓰기

    DevysNounFilterTest 함수setUp() 에 있는 DocsList은 명사, 명사+조사 를 이미 쪼게 놓은건가요?
    이미 쪼개 놓은 것중 엔진에 있는 DevysNounEngine에 있는 명사와 비교해서 명사와 같은 문자가 있으면
    명사를 출력해주는 건가요?

  10. 2013.11.24 23:17  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

    • 용식 2013.11.25 07:11 신고  댓글주소  수정/삭제

      안녕하세요?
      질문을 많이 주셨었네요...ㅎㅎ 제가 지금 댓글을 확인했네요 --ㅋ
      우선 Tokenizer부터 작업해야 한다고 말씀드린 부분은 [2am]이 [2][am]으로 분석되는 형태 (한글과 영어분리등)는 Tokenizer에서 작업하는 것이 더 수월하기 때문입니다. 사실 Filter에서도 잡아서 할 수는 있지만요... 현재 사용하고 계신것이 Whitespace Tokenizer라면 중복 설정이 아니라 Tokenizer 교체를 말씀드린거고.. Tokenizer가 Filter랑은 작동 방식이 좀 다르기 때문에 어려우시면 그냥 Filter에서 쪼개셔도 됩니다. ascii등으로 확인해야겠죠~

      Test 코드의 setUp에 있는 DocsList는 사전의 목적이 아니라 추출된 명사를 Assert로 점검하기 위한 명사 리스트입니다.

      만약에 전체 full source가 필요하시면 우선 이쪽을 먼저 확인해보세요.

      https://github.com/need4spd/lucene-Korean-Analyzer

      제가 Lucene 4.X대의 코드도 같이 올려놓았으니 아마 도움이 되실거에요.. 부분적인 질문에 대한 답변은 메일로 가능하겠지만.. 문의주신것처럼 Tokenizer와 Analyzer 전체 내용을 모두 설명드리기에는 메일로도 사실 좀 버거워서.. 양해부탁드립니다.

      감사합니다. :)

로그분석/조회 프로그램에 대한 루씬쪽 약간의 변경작업..

1. 전체 document 약 2억6천만개
 - 하루 약 40만개씩 증가 
 - 색인파일 연도별로 분리
 - MultiSearcher 사용

2. 검색속도 개선을 위한 작업
 - date필드를 int형 필드로 마이그레이션
 - value 필드를 int형 필드로 마이그레이션
 - rangeQuery를 Filter로 변경하여 검색

3. 리소스 개선을 위한 작업
 - long타입을 int로 마이그레이션 

까지는 완료... 

4. 더 할 수 있는 작업
 - 색인 조건 최적화
 - TermVector, Norm값등은 필요 없으므로 가지고 가지 않도록

5. replication 기능 개발

역시 책을 읽어야해...;;

1판에 있던 내용도 지금 다시 읽으니 참 새롭다.
Posted by 용식
TAG 루씬

댓글을 달아 주세요

이번에는 동의어 필터를 만들어보겠습니다. 동의어의 역할은 다 아시겠지만 특정한 키워드에 대해서 확장된 검색을 지원 할 수 있도록 해줍니다. 이 예제도 앞선 예제들과 마찬가지로 루씬인액션의 책에 있는 예제를 보고 우리 상황에 맞춰 다시 만든 코드들입니다. 먼저 테스트 케이스를 보겠습니다. DevysSynonymFilterTest.java
테스트 케이스는 양방향으로 검사를 하고 있습니다. 
동의어 목록을 미리 지정해두고 TokenFilter로부터 나오는 Token을 리스트와 비교.. 
그리고 이 Token을 따로 저장해두고 다시 동의어 목록을 가지고 비교합니다. 

 예를 들어서 동의어가 "노트북, notebook, 노트북PC"로 등록되어 있다면 3개 중 하나의 키워드가 
검색쿼리로 들어온다고 해도 동의어로 등록된 3개의 키워드 전부로 검색이 되는 효과를 줄 수 있습니다. 
이걸로 어느정도 오타에 대한 커버도 가능하겠죠. 보통 동의어 사전을 저런식으로 모두 동등한 키워드로 
등록하는 것과 노트북:notebook, 노트북PC 이런식으로 대표어를 설정하여 등록하는 방식 두가지를 
생각해볼 수 있겠습니다. 

관리적인 면에서는 후자가 편하겠지만 확장성에서는 전자가 편하다고 생각되므로 
전자의 방식으로 동의어 필터를 구현하겠습니다. 

이 동의어의 확장을 검색시에 수행 할 것이냐 색인시에 수행 할 것이냐도 
조금 생각 해 볼 만한 문제입니다. 검색시에 확장을 하게 된다면 "노트북"이라는 검색 쿼리에 대해서 
실제로 검색엔진에서 "노트북" or "notebook" or "노트북PC"와 같은 형태로 쿼리 자체가 확장 될 수도 있겠습니다. 

하지만 루씬에서의 동의어 검색은 위와 같은 방법이 아니라 색인시에 동의어를 사용하여 같이 색인을 하고 동의어가 어떤 것이 들어오던 검색이 가능하도록 해주고 있습니다. 

특히 이 소스에서는 동의어를 루씬 RamDirectory를 사용 색인하여 두고 활용하기 때문에 동의어들에 대해서 어느 동의어가 들어오더라도 똑같은 기능을 하도록 구현하였습니다. 기본적인 컨셉은 이렇습니다. 

 루씬에서 하나의 Document에는 같은 이름의 필드를 여러개 등록 할 수 있습니다. 
이것을 활용하여 루씬의 RamDirectory를 사용하여 색인하여 활용 하려고 합니다. 
동의어 사전이 위와 같이 ROW단위로 등록이 되어 있다고 할 때 각 ROW를 읽어 ","로 split을 한 후 하나의 Document에 동일 필드명(syn)으로 집어 넣어 루씬의 RamDirectory를 활용하여 색인합니다. 

 그리고 검색 쿼리가 들어 올 경우 그 쿼리로 색인된 동의어 리스트에서 검색을 하여 나온 Document에서 syn필드의 값들을 꺼내 그것으로 검색을 하도록 하는 것 입니다. 


대부분의 사전을 이렇게 색인을 활용 할 수도 있을 것 입니다. 
이렇게 추출된 동의어의 리스트를 실제 필터에서 어떻게 활용하는지 보겠습니다. 
이번에 만들어질 동의어 필터는 SynonymEngine 이라는 클래스를 내부적으로 사용하고 있습니다. 
 이 SynonymEngine은 Engine 인터페이스를 상속한 것으로써 Stack를 리턴해주는 getAttributeSources 메서드를 하나 가지고 있습니다. 
 이 SynonymEngine은 위 예제처럼 색인과 검색을 통하여 동의어의 리스트를 받아와서 이를 AttributeSource에 저장하여 이것의 State를 Stack에 저장합니다.


DevysSynonymFilter.java
이제 State에 대한 이야기를 해야 할 것 같습니다.

필터 중 이렇게 Tokenizer로부터 추출 된 하나의 Token으로부터 여러개의 Token을 추가적으로 추출해내는 필터들이 있습니다. 
 동의어 필터라던가 명사 추출 필터 같은 경우 하나의 Token으로부터 추가적인 Token들을 추출해내죠. 
 이런 경우에 필터는 원본 Token을 저장해두고 추가적으로 추출된 Token을 Collection 혹은 Stack에 넣어두고 그 Token들을 리턴한 후 맨 마지막에 원본 Token을 리턴하여 작업을 끝내는 방식을 사용 할 수 있습니다. 
(보통 한번 사용 된 Token은 사라져야 하기 때문에 Stack등이 많이 사용됩니다.) 

 이때 사용되는 것이 State입니다. 

 2.X에서 3.X로 넘어오면서 Token의 각 속성에 대해 Attribute라는 클래스가 생긴 것과 이 State가 생긴 것이 Analyzer의 내부 구현에서 가장 큰 변화라고 생각합니다. 
 처음에 이 개념을 이해하는데 조금 애를 먹었는데요 공부했던 것이 오래전이라서 저도 다시금 기억을 되살려가며 설명 드리려 합니다. 

 앞에서 언급했지만 Analyzer의 Tokenizer와 모든 TokenFilter들은 TokenStream 클래스를 상속하고 있습니다. 
 이 TokenStream클래스는 내부적으로 AttributeSource 클래스를 상속받고 있고 이 AttributeSource 클래스가 이전 Token에서의 속성들인 CharTermAttribute등 여러 종류의 Attribute들을 가지고 있는 구조입니다. 

 때문에 데코레이터패턴으로 물려있는 Tokenizer와 TokenFilter들이 모두 이 속성을 공유하여 작업 할 수 있는 것 입니다. 그렇다면 만약 A라는 단어로부터 B,C,D라는 동의어가 추출된다고 할 때 예전 같으면 이 추출된 키워드를 가진 Token 클래스를 다음 Filter에게 넘겨주면 되는 구조였지만, 

이제는 B,C,D 각 동의어들의 속성을 가진 AttributeSource를 만들어내어 다음 Filter가 사용 할 수 있도록 해줘야 하는 것 입니다. 

하지만 3.X의 루씬에서 incrementToken 메서드는 더 이상 Token을 리턴해주는 것이 아니라 단순히 boolean 값을 리턴해줍니다. 위에서 몇번 언급을 하였듯이 TokenSteam들은 AttributeSource를 공유하여 사용하는 구조입니다.

여기서 바로 AttributeSource의 captureState와 restoreState 메서드가 사용됩니다. 

기본적으로 추출된 키워드를 포함한 Attribute들이 모두 AttributeSource에 들어있는 것이기 때문에 이 AttributeSource 자체를 저장해뒀다가 꺼내서 사용하는 개념입니다. 

선행 Tokenizer나 TokenFilter로부터 넘어온(실제로는 AttributeSource에 저장해둔) AttributeSource를 복사하여 이것을 가지고 동의어에 대한 속성을 만들어서 Stack에 그 State를 저장하여 둡니다. 왜 복사를 하냐면 우리는 원본에 대한 정보도 필요하기 때문입니다. 원본에 대해서 뭔가 조작을 해버리면 원본 속성을 잃어버리기 때문에 복사를해서 작업을 하는 구조로 되어있는 것 입니다.
SynonymFilter의 incrementToken 메서드를 보면

synonyms = engine.getAttributeSources(input.cloneAttributes());


이런 부분이 있습니다. 동의어를 추출 할 SynonymEngine에게 현재 Token의 정보를 가지고 있는 AttributeSource를 복사하여 넘겨주고 있습니다. 그러면 SynonymEngine에서는 동의어 추출을 위해 필요한 정보들을 이 AttributeSource로부터 얻어 올 수 있습니다.
SynonymEngine에서는 이 복사된 AttributeSource를 사용하여 동의어에 대한 속성들을 Set한 후 이 AttributeSource의 State를 Stack에 저장합니다.

synonymStack.push(attributeSource.captureState());


그리고 incrementToken 메서드에서는 Stack의 size를 체크하여 Stack이 채워져있으면 그 Stack으로부터 State를 받아와서 현재 TokenStream의 AttributeSource를 Stack에 넣어졌던 State(동의어 정보)로 변경합니다.

restoreState(synState);


그리고 true를 리턴하지요. 그러면 후행 TokenStream에서는 추출 된 동의어에 대해서 작업이 가능합니다.

물론 incrementToken 메서드를 보시면 Stack에 비어있는 경우 맨 끝에서 true를 리턴해주기 때문에 동의어가 없는 경우나 동의어를 모두 추출한 이후 원본 AttributeSource도 후행 TokenStream에서 사용 할 수 있게 되어있습니다. 

이 개념이 조금 어려울실 수도 있겠지만 AttributeSource를 중심으로 생각하시면 조금은 쉽게 이해가 되실 것 입니다.
그리고 동의어 필터에서는 원본 Token으로부터 파생된 동의어들의 위치정보값을 모두 0으로 셋팅하여주고 있습니다.  

이것은 이전 Token(여기서는 원본 Token)과의 위치거리가 0이라는 뜻으로 결국 원본 Token과 같은 위치에 있는 동일한 Token이라는 의미로 사용됩니다. phraseQuery에서는 중요한 개념이지만 저희는 직접 QueryParser를 만들어 사용 할 것이기 때문에 크게 중요하게 사용되는 속성은 아닙니다. 

 하지만 꼭 알고 넘어가야하는 속성이기도 합니다.
PositionIncrementAttribute positionAttr = savedAttributeSource.addAttribute(PositionIncrementAttribute.class); //원본 AttributeSource의 Attribute를 받아옴 positionAttr.setPositionIncrement(0);
https://github.com/need4spd/aboutLucene 에서 체크아웃 받으 실 수 있습니다.


Posted by 용식

댓글을 달아 주세요


글 쓰다가 좀 애매한 부분이 있어서.. 3년전에 공부 할 때 읽었던 루씬인액션 초판 번역본을 꺼내보았다.
루씬 1.4를 기반으로 쓰여진 루씬인액션 초판 번역본이다. 

책장에서 책을 꺼냈는데 책에 붙어있는 인덱스들이 눈에 들어왔다.

책을 열어보니 여기저기.. 지금보면 나는 또 모를 얘기들이
연필이고 볼펜으로 적혀있었다. 

나는 3년전 그때 당시 팀장님과 추경돈대리님의 명(?)을 받고
검색에 "검"자와 자바의 "자"자도 모르면서
뭘 만들어보겠다고 이렇게나 열심히 공부했을까.. 싶은 생각이 든다.

이 책 번역해주신 이문호님께서는 가끔 블로그에 좋은 댓글을 남겨주시고.. 내가 작성한 포스트에 오류가 있으면
지적해주셔서 수정 할 수 있게 도와주시기도 한다. 
또 한분의 역자이신 강철구님과는 루씬 user group에서 메일로 질문남기다가
알게 되어 이곳 페북에서까지 친구(?)맺고 지내고있고.. 난 이분께서 번역하신 책을 3권 정도 가지고 있다. ㅋㅋ

좋은책이 한글로 번역되어 있다는 것이 정말 좋은 것이구나 라는 생각을 이때 처음 했던 것 같다.

그리고 이 두분과 또 한분의 공역자분은 내 첫 회사에서 같이 근무하셨던
(나는 파견나가 있어서 실제로 많이 뵙진 못 했었지만) 분들이다. 세상이 참 좁은 정도가 아니라 이럴 수가 있나 싶기도..ㅎㅎ

아무튼 나는 그 당시 뭔가 해보라는 명은 떨어졌고... 믿을 것은 이 책 밖에 없었고
그래서 죽어라고 이 책만 읽으며 소스열어보고 테스트해보고 정말 이것저것 맨땅에 헤딩하면서
해봤던 기억이 있다. 꽤 절실하게...
 
덕분에 이 책으로 루씬과 검색에 대해서 공부하게 되었고, 자리를 옮기면서 풍주형님께는 자바를 배울 수 있었다. 

약 5년전에 검색운영업무를 처음 시작한 이후로 지금까지 계속 검색업무를 해오고 있기는 하지만
사실 3년전에 루씬을 접하게 되면서 루씬에 대한 공부와 함께 풍주형님에게 자바에 대한 많은 것들을
배울 수 있었던 것이 내 개발자 로드맵에 있어서 굉장히 큰 전환점이 되었던 것 같다. 

그리고 이것 덕분에  비록 온라인상에서이지만 다른 실력 좋으신 분들도 알게 되었다. 

우연찮게 옛날 책을 꺼내보다가 
책에 무수히 붙어있는 인덱스를 보며 왠지 감회가 새로워 블로그에 그냥 두서없이 끄적여본다...

근데 갑자기 딸래미가 울어대서 흥이 깨졌다. 이만~~~
 
Posted by 용식

댓글을 달아 주세요

그러면
이제 루씬에서의 Analyzer에 대해서 살펴보도록 하겠습니다.

이제부터 정말 작성하기가 조심스러워지네요... 저도 루씬을 잘 이해하고 있는 상태는 아니라서.... ㅎㅎ 
더군다나 제가 공부하며 만들었던 루씬 버전이 2.4 정도 버전인데 현재 3.3 버전은 기본적인 개념은 비슷하겠지만
내부적은 구현 방식은 완전히 바뀌었더라구요...

앞으로 내부적인 루씬 소스에대한 설명보다는 주로 사용법/커스터마이즈 방법을 소개해드리게 될 것 같습니다. 

우선 Analyzer가 어떻게 사용되는지 보도록 하겠습니다.


AnalyzerUsageSampleTest.java
우선 분석 할 문장을 StringReader로 생성하고, 이 문장을 분석 할 Analyzer를 생성합니다.
여기서는 WhitespaceAnalyzer를 이용하였습니다.

StringReader stringReader = new StringReader("집에서 블로그를 작성합니다.");

Analyzer analyzer = new WhitespaceAnalyzer(Version.LUCENE_32);


루씬에서 Raw데이터를 분석하기 위해서 쓰여지는 타입이
StringReader입니다. Raw데이터가 웹페이지던, 혹인 DB에 있는 데이터던
StringReader타입으로 변환해주면 얼마든지 루씬을 사용하여 색인/검색을 할 수 있습니다.
Analyzer에는 많은 종류가 있습니다. 여기서 사용한 WhitespaceAnalyzer
문장을 "공백"을 기준으로 분리하여 키워드를 추출하는 Analyzer입니다.
이 외에도 KeywordAnalyzer, SimpleAnalyzer, StandardAnalyzer등 많은 Analyzer가 존재합니다.
대략 이름만봐도 어떻게 키워드를 추출 할지 짐작이 되실 것 입니다.
WhitespaceAnalyzer의 생성자에서 사용되는 VersionAnalyzer 내부적으로
Api를 선택적으로 사용하기 위한 방법으로 보입니다. 아마 Lucene 4.0 정도가 되면
전부 정리가 되지 않을까 싶네요. 여기서는 크게 신경쓰지 않겠습니다.
그리고 문장과 필드(여기서는 title)를 가지고 TokenStream을 생성합니다.

TokenStream tokenStream = analyzer.tokenStream("title", stringReader);

여기서 "title"은 이 예제에서는 사용되지는 않습니다.
그리고 TokenStream으로부터 3가지의 Attribute를 받아옵니다.

CharTermAttribute termAtt = tokenStream.getAttribute(CharTermAttribute.class);

PositionIncrementAttribute posIncrAtt = tokenStream.addAttribute(PositionIncrementAttribute.class);

OffsetAttribute offsetAtt = tokenStream.addAttribute(OffsetAttribute.class);


바로 CharTermAttribute, PositionIncrementAttribute, OffsetAttribute 입니다.
이 3가지는 사실 루씬에서는 실제로 Token이라는 클래스가 사용되며 이 Token이라는 클래스안에 필드데이터로 들어있던 값들 입니다.

다만 3.X부터는 내부적으로 Token이 아니라 위 Attribute를 사용하도록 전체적으로 수정이 된 것 같습니다. 물론 Token 클래스도 여전히 존재합니다.
 
CharTermAttribute은 추출된 키워드를,
PositionIncrementAttribute 는 키워드가 바로 이전에 추출된 키워드로 부터 얼마나 떨어져있는지를,
OffsetAttribute 은 키워드가 문장에서 어느 위치에 있는지를 나타내는 속성입니다.

그리고 이 속성들은 모두 TokenStream이 상속하고 있는 AttributeSource 클래스에
속한 속성들입니다. 이후 AttributeSource라고 말씀드리면 이런 모든 속성들을 가지고 있는
클래스라고 생각해주시면 좋을 것 같습니다.
 
특히 CharTermAttribute를 대량의 데이터를 색인 할 때 리소스 활용과 속도를 위해서
char[]를 사용하여 재사용성을 최대로 높이고 있습니다.

위 3가지 속성은 색인/검색에 있어서 상당히 중요한 속성입니다.

CharTermAttriute는 추출된 키워드 그 자체를 나타내기에 당연히 중요한 속성이고
PositionIncrementAttribute는 검색을 할 때 "나이키와 운동화의 사이에 단어가 3 이하인 문장" 이러한식으로
검색을 할 때 사용됩니다.
OffsetAttriute는 제가 사용 했을 때는 검색된 결과에서 Match된 부분을 Highlight하여 보여줄 때
사용되었었습니다. 이 부분을 잘 이해하고 있어야 동의어에 의한 검색시에도 제대로 Match된 키워드에 Highlight하여 보여 줄 수 있습니다.

결국 Analyzer가 하는 역할은 색인 및 검색 대상이 되는 원문으로부터 키워드를 추출하고,
검색과 부가 정보 표시에 필요한 각종 속성들을 생성하여 넘겨주는 역할을 한다라고 볼 수 있을 것 입니다. 

더 자세한 내용은 책을 참고하시면 좋겠죠. ^^

그 후 TokenStream으로부터
추출되어 나오는 키워드가 없을 때 까지 loop를 돌면서
각 속성의 값을 추출하는 형식입니다.

-- 결과 --

text : 집에서

postIncrAttr : 1

startOffSet : 0

endOffSet : 3

text : 블로그를

postIncrAttr : 1

startOffSet : 4

endOffSet : 8

text : 작성합니다.

postIncrAttr : 1

startOffSet : 9

endOffSet : 15


이제 대략적인 사용법이 눈에 들어오시나요?
이렇게 TokenStream으로부터 추출되는 Attribute들을 사용하여 키워드를 색인하고 검색하는 것 입니다.

앞으로 Token이라는 것은 "키워드","위치정보"등이 포함된 데이터라고 생각해주시면 됩니다.
(위 3가지 속성의 합이라고 생각해주셔도 됩니다.) 
 

실제로 2.X 버전까지는 Token이라는 클래스를 TokenStream으로부터 받아서 사용하였지만
3.X로 올라오면서 Token을 사용하지 않고 위 예제처럼 TokenStream으로부터 필요한 Attribute를 얻어서
사용하는 방식으로 변경되었습니다.


Analyzer는 데코레이터 패턴으로 구현이 되어있습니다.
Analyzer는 크게 TokenizerTokenFilter를 사용하여 구성되어 있고
이 두 클래스는 모두 TokenStream을 상속하여 구현되어 있습니다. 실제 소스를 열어보시면 더 많은 클래스들이 관여하고 있고
3.X 버전에서 보면 구현하는 형태도 많이 변경되었지만 기본적인 개념은 TokenStream 클래스를 사용한 데코레이터 패턴입니다.

Tokenizer는 문장을 읽어서 자기가 가진 룰대로 문자를 잘라 Token으로 만들어내는 역할을하며,
이 
Tokenizer의 결과를 TokenFilter가 받아서 사용하고 또 그 결과를 TokenFilter가.. 어쩌구 저쩌구..
이런 형태로 구현이 되어있습니다.

new TokenFilter(new TokenFilter( new Tokenizer()));

//3.X에서는
TokenStreamComponents를 이용하여 구현 형태를 다소 다르게 하고 있습니다.
Tokenizer source = new Tokenizer();
new TokenStreamComponents(source , new TokenFilter(new TokenFilter(source)));

왜 이런형태가 되었는지는 저도 좀 살펴봐야 할 것 같네요..  


당연히 제일 안쪽에는 Tokenizer가 들어가야 할 것 입니다. 기본적으로 문장으로부터 Token이 추출되어야
Token을 가지고 Filter가 일을 할 수 있을테니까요.

실제로 불용어 리스트를 가지고 추출되는 Token이 해당 불용어와 일치하는 경우
Token을 버리는 StopAnalyzer는 아래와 같이 작성되어 있습니다.

final Tokenizer source = new WhitespaceTokenizer(matchVersion, reader);

return new TokenStreamComponents(source, new StopFilter(matchVersion, source, stopwords));


new TokenStreamComponents는 크게 신경쓰지마세요. 결국 우리가 사용하게 되는 것은
TokenStream입니다. 위 예제에서 analyzer.tokenStream()은 내부에서 결국
new TokenStreamComponents().getTokenStream()으로 연결됩니다. StopFiltersource를 생성자 파라메터로
받고 있는 것을 보실 수 있습니다.
위 예제에서 analyzer.tokenStream() TokenStreamComponents.getTokenStream()에 의해서
StopFilter
return됩니다. 따라서 결국 tokenStream.incrementToken() StopFilter incrementToken()
호출 되고 이 메서드 내부에서 StopFilter가 생성자 파라메터로 받은 source 즉, Tokenizer incementToken()
메서드가 선 호출되어 결과를 받아 처리하는 형태로 되어 있습니다. 

Tokenizer는 문장에서 기본적으로 키워드를 추출해내는 룰을 가지고 있습니다.
WhitespaceAnalyzerWhitespaceTokenizer를 사용하여 키워드를 추출하도록 되어 있고
WhitespaceTokenizer는 공백을 기준으로 하나의 Token를 생성하여 넘겨주도록 되어있습니다. 
즉, Tokenizer를 직접 구현하면 문장에서 추출되는 Token을 우리가 원하는대로 만들어 낼 수 있습니다.

단순히 공백이 아니라, 
 

mp3 -> [mp] + [3],

ipad사용자 -> [ipad] + [사용자]


등으로 한글과 영어/숫자를 분리하여 Token으로 추출해
낼 수도 있습니다.
잠깐 다른 이야기를 하면 영어,숫자와 한글은 기본적으로 분리를 하는 것이 좋기는 하지만 "넘버3" 같은 영화제목을 생각하면
붙이는 것이 맞을 수도 있습니다.

그래서 앞선 포스트에서 말씀드렸듯이 비지니스와 언어등에 따라서 추출되는 방법이 다 달라질 수 있다는 것 입니다.

그리고 TokenFilter가 있습니다. 이 TokenFilterTokenizer를 감싸는 형식 (데코레이터 패턴이라고 말씀드렸었죠?)으로
사용됩니다. 일반적으로 Tokenizer로부터 추출 된 Token에 뭔가 추가적인 작업을 하는 형태로 구현이 됩니다.
즉, 위 예제 코드에 있는 문장의 경우 공백을 기준으로 "집에서" 라는 키워드가 하나의 CharTermAttribute로 추출된다고 하면
여기서 "에서"라는 어미를 제거하여 최종적으로 "집"이라는 키워드를 추출한다던가 "집에서"라는 키워드 자체를
불용어로 등록하여 아예 아무런 키워드를 추출하지 않도록 하는 작업등을 TokenFilter가 하게 됩니다.

그러면 여기까지의 내용을 가지고 앞으로 우리가 무엇을 만들어야 할지 한번 살펴보겠습니다.
우선 주어진 문장으로부터 Token을 추출해야 합니다. Tokenizer를 구현해야 합니다.
하지만 공백을 기준으로 Token을 추출하면 한글에서는 큰 가치가 없는 Token일 경우가 많습니다.
따라서 우리는 이 Token으로 부터 의미있는 키워드를 추출해야 합니다.
Tokenizer에서 공백을 기준으로 Token을 만들어 넘겨주면 키워드를 추출하는 작업은 TokenFilter를 사용해서 진행합니다.
저희는 추가적으로 공백 + (한글과 영어/숫자)를 분리하여 넘겨주도록 해보겠습니다. 
기본적으로 "어미사전"으로부터 어미를 제거하는 StemFilter
"동의어 사전"으로부터 동의어를 추출하는 SynonymFilter
"명사사전"으로부터 명사를 추출하는 NounExtractFilter
"불용어사전"으로부터 불용어 처리를 하는 StopFilter
"복합명사사전"으로부터 주어진 키워드를 사전에 정의된 명사들로 추출하는 CompoundsNounExtractFilter
등을 생각해 볼 수 있겠습니다.
각각의 Filter들은 실제로 구현을 해보면서 Analyzer를 완성시켜보도록 하겠습니다.
가장 간단한 형식으로 구현 할지 모든 내용을 다 담아서
구현을 할지 아직 결정을 하지 못 했네요...
설명을 어떻게 해야할지도 중요한 문제라서 모든 내용을 다 담아서 전부 구현을 하게 되면.. 그것만으로도
글의 분량이 어마어마해질 것 같아서... 고민이네요...

포스트에 사용된 예제는https://github.com/need4spd/aboutLucene에서 체크아웃 받으 실 수 있습니다. 
Posted by 용식

댓글을 달아 주세요

앞에 데모 프로그램을 보셔서 아시겠지만
검색이라는 하나의 작업을 위해서는 
크게 색인과 검색이라는 2가지 프로세스를 거쳐야 합니다.

이중 색인과 검색에 모두 관여하는 것이 하나 있는데
그것이 바로 키워드 추출.. 즉, 흔히 이야기하는(하지만 너무나 어려운)
형태소분석입니다. (이후에는 키워드 추출이라고 하도록 하겠습니다.)

잠깐 루씬을 떠나서
검색이론에 대해서 이야기 해 보겠습니다.

흔히 검색에서 이야기하는 색인 파일은 대부분 "역색인(Inverted Index)" 구조로 되어있습니다.

이게 어떤 구조인가하면.... 책의 맨 뒤 부록에 나오는
"단어"를 기준으로 페이지를 나열한 형태의 색인 구조라고 보시면 됩니다.

나이키 - Doc1, Doc2, Doc3
운동화 - Doc1, Doc5
청바지 - Doc6, Doc7


이런식으로 키워드를 기준으로 그 키워드가 포함된 문서들을
정의해 놓은 형태입니다.

"나이키"라는 검색어가 들어오면 Doc1, Doc2, Doc3을 찾아서 화면에
보여주는 것이지요.

"나이키와 운동화"라는 검색어가 들어오면
"나이키"에 연결된 Doc1, Doc2, Doc3
"운동화"에 연결된 Doc1, Doc5 를 찾아서
And 연산으로 Doc1을 찾아서 결과로 보여주는 구조입니다.

Doc1, Doc2, Doc3
&
Doc1, Doc5
= Doc1 


물론 재검색의 Boolean 조건이 OR라면 Doc1, Doc2, Doc3, Doc5가 전부 보여질 것 입니다.

보시면 아시겠지만 "키워드" 기반의 검색에 최적화 된 구조라고 볼 수 있습니다.

이러한 색인구조에는 미리 스코어를 지정 해 둘 수
없는 구조이기 때문에 Ranking이 그만큼 중요해지는 구조이기도 합니다.

그리고, 위의 예만 보시더라도
Doc1~Doc7까지의 문서에서 나이키, 운동화, 청바지등 사용자들에게
혹은 비지니스안에서 의미가 있는 키워드를 추출해내는 것과
사용자가 입력한 키워드인 "나이키와 운동화"에서 "나이키", "운동화"를 추출해내는 것이
얼마나 중요한 작업인가도 알 수 있습니다.

이 키워드가 추출되지 않으면
검색을 할 수가 없는 것 입니다.

이것이 검색에 있어서 키워드 추출(형태소 분석)이고
이 역할을 해주는 것이 루씬의 Analyzer입니다.

사실 루씬으로 검색엔진을 만든다고 할 때
가장 먼저 부딫히게 되는 장벽이 바로 이 Analyzer의 구현이고
또 가장 어려운 부분중에 하나가 역시 이 Analyzer입니다.

검색이라는 그 행동 자체는
어디서나 동일하겠지만 (랭킹의 구현은 다르겠지요..) 키워드 추출이라는 것은
나라별로, 언어별로 심지어 비지니스(1)별로 원하는 추출 방식이 모두 다르고, 추출해야하는
방법도 모두 다르기 때문입니다.

본격적으로 들어가기전에 키워드 추출에 대해서 조금 더 살펴보도록 하겠습니다.

초반에는 문장에서 (혹은 웹 페이지에서) 의미있는 키워드라는 것이 대부분 "명사"에 한정되는 구조였습니다.
전반적으로 색인 대상이 되는 문서가 많지 않았기 때문에 문장에 들어있는 중요한 명사들만
추출해서 색인을 해도 큰 문제가 없었울 것 입니다.

하지만 요즘에는 이렇게 명사만을 추출하는 것은 
큰 의미가 없을 것 같습니다.(2)

"사랑합니다"라는 노래를 찾아보기 위해 구글에서 "사랑"이라는 키워드로 검색을 해보세요.

"사랑"이 들어간 무수한 페이지들이 나올 것 입니다. 정말로 내가 원하는 것을 찾기 위해서는
"사랑"이라는 단어 하나만으로는 많이 부족합니다. 

최근에는 단순히 키워드만을 추출하는 것이 아닌, 점점 자연어에 근접한 키워드로 검색이 가능하도록
해주는 것이 중요해지고 있습니다. 물론 이러한 수준까지 이번 프로젝트(?)를 통해서
구현하지는 못 할 것 입니다. 사실 형태소 분석도 구현하기가 아주 어려워요..--;
참고로 이수명님께서 만드신 루씬용 형태소 분석기가 있습니다.

이번에는 미리 만들어진 사전을 기반으로 하여
문장과 입력된 키워드로부터 단어를 추출하는 수준으로
구현을 해 볼 예정입니다.

그리고 또 하나는 색인 할 때의 키워드 추출과 검색어로 들어온 키워드를 분석 할 때에는
약간의 차이가 있어야 한다는 것 입니다.

Doc1 : "삼성은 우리나라에서 가장 큰 기업이다. 전자부문을 비롯하여...." 
Doc2 : "삼성전자는 이번에 갤럭시S를 발표하였다."


라는 두개의 문장이 있다고 가정을 해보겠습니다.

Doc1에서의 키워드는 "삼성" , "우리나라" , "기업", "전자", "부문", "전자부문" 등이..
Doc2에서의 키워드는 "삼성" , "전자", "삼성전자", "갤럭시S", "발표" 등이 나온다고 볼 수 있을 것 입니다.

이렇게 색인이 되어 있는 상태에서
한명의 사용자가 검색어로 "삼성전자"를 입력하였습니다.

위 문장에서 키워드가 추출된 결과를 참고해보면 "삼성전자"로부터 "삼성"+"전자"
추출이 되는 것이라고 생각해 볼 수 있습니다.
그렇다면, 사용자가 "삼성전자"와 관련된 문서를 찾고 싶어서 입력 한 검색어를 "삼성""전자"
분석하여 검색이 되도록 해야 할까요? 그렇다면 Doc1과 Doc2과 모두 검색이 될 것 입니다.

하지만, Doc1은 사실 삼성전자와는 별 관련이 없는 글입니다.

오히려 사용자가 입력한 "삼성전자"라는 검색어를 특별히 분석하지 않고
그대로 검색이 되도록 한다면 Doc2만이 검색이 될 것이고 이것이 더 좋은
결과 값이라고 생각이 될 수도 있습니다.

사실 이러한 부분은 recall(재현율)과 precision(정확도) 중 어느 곳에
더 비중을 두느냐 혹은 위와 같은 상황에서 아예 Doc1을 검색 결과에서 제외를 시킬 것이냐
Ranking으로 해결 할 것이냐등에 따라서 정답이 없는 이야기이지만
키워드 추출이라는 것이 단어를 무조건 많이 추출한다는 것이
답은 아니라는 것을 보여드리기 위해서 언급을 해보았습니다.

이번에 직접 구현 할 Analyzer에서는 위 기준에 맞춰서
색인용과 검색어 분석용으로 나누어 구현을 할 예정입니다.

작성을 하고 이번 포스트를 마무리 하려고 하니..
이번 내용에서는 루씬에 대해서는 전혀 내용이 없네요..--;

하지만 검색 엔진을 만든다고하면 반드시 꼭 집고 넘어가야 하는
부분이기도 합니다. 최근의 검색 엔진들은 자연어 검색도 지원해주고 있기 때문에
여기서 이야기된 많은 부분들은 이미 검색엔진이 태동 될 초창기때
모두 한번씩은 언급이 되었던 내용들이라고 생각합니다.

하지만 뒤따라가는 입장에서
이미 지나간 이슈라고 그냥 넘어 갈 수는 없겠죠..^^

다음 포스트에서 
루씬에서 구현되어 있는 Analyzer의 구조와 가장 간단한 Analyzer하나를 뜯어보고
이를 바탕으로 저희만의 "사전기반의 명사 추출" Analyzer를 
만들어 나가보도록 하겠습니다.

하루써서 하루 올리는 입장이라서
글 올라오는 속도가 매우 들쑥날쑥 할 것 같습니다. 보통 애 재우고나야 
앉아서 뭘 쓸 시간이 생겨서요...ㅎㅎ

이해부탁드릴게요..^^

그리고 저도 검색/루씬 초짜입니다. 또 구현된 소스를
보시다보면 구현 자체에 문제가 있을 수도 있습니다.

내용 중 오류나 잘못된 부분들이 있으면
언제든 말씀해주세요.

감사합니다.

(1) - 쇼핑몰에서의 키워드 추출과 포털에서의 키워드 추출이 완전히 같을 수는 없을 것 입니다.
(2) - 쇼핑몰등에서는 조금 다릅니다. 명사/모델명등 단어만 추출하는 것이 충분히 의미가 있을 수 있습니다. 
(3) - 이 카페에 가시면 관련 내용들을 보실 수 있습니다.

Posted by 용식

댓글을 달아 주세요