본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 4. 검색(1) - IndexSearcher

by 잭피 2020. 11. 29.

검색은 5가지 주제를 통해 알아보겠습니다

1. 루씬 색인의 문서 검색
2. 다양한 루씬 내장 질의 활용
3. 검색 결과 활용
4. 연관도 점수 계산 방법
5. 사람이 입력한 질의 변환

 

앞에선 검색 준비 단계인 색인을 구축하는 과정을 설명했습니다

이제 색인 과정에들인 노력을 활용할 수 있는 방법을 살펴봅시다


루씬의 핵심 검색 API

IndexSearcher : 색인을 검색하는 기능을 담당하는 클래스

Query : 실제 Query 하위 클래스에서 특정 질의 기능을 구현

QueryParser : 텍스트 형태의 질의를 분석해 루씬 Query 객체로 변환

TopDocs : IndexSearcher.search 검색한 결과 중 연관도 점수가 가장 높은 결과 문서를 담음 (내림차순)

ScoreDoc : TopDocs 클래스에 담긴 검색 결과 하나를 나타내는 클래스

간단한 검색 기능 구현

텀 검색

IndexSearcher

색인의 내용을 검색하려 할 때 가장 핵심이 되는 클래스입니다

search 메소드로 텀을 포함하는 문서를 검색할 수 있습니다

(텀은 필드 이름과 내용 단어 문자열을 담고 있는 객체입니다)

 

원본 텍스트는 분석 과정을 거치며 정규화됐을 가능성이 높습니다 예) 불용어 제거, 모두 소문자 따라서 검색할 때 색인 과정에서 사용했던 분석기와 같은 분석기를 지정해야 합니다

 

루씬에는 여러 종류의 Query 객체가 있습니다

그 중에 TermQuery가 가장 기본적인 객체입니다

 

// TermQuery로 검색
public class BasicSearchingTest extends TestCase {
  public void testTerm() throws Exception {
    Directory dir = TestUtil.getBookIndexDirectory(); //A
    IndexSearcher searcher = new IndexSearcher(dir);  //B

    Term t = new Term("subject", "ant");
    Query query = new TermQuery(t);
    TopDocs docs = searcher.search(query, 10);
    assertEquals("Ant in Action",                //C
                 1, docs.totalHits);                         //C

    t = new Term("subject", "junit");
    docs = searcher.search(new TermQuery(t), 10);
    assertEquals("Ant in Action, " +                                 //D
                 "JUnit in Action, Second Edition",                  //D
                 2, docs.totalHits);                                 //D

    searcher.close();
    dir.close();
  }

 

검색한 결과로 search 메소드에서 TopDocs 객체를 리턴받습니다

일반적으로 IndexSearcher와 Directory 객체 모두 항상 열어두고 검색 질의를 모두 처리하게 하는 편이 좋습니다

IndexSearcher 객체를 새로 열려면 색인의 정보를 읽어와 내부적으로 검색에 필요한 기본 자료 구조를 구성해야 하기 때문에 검색 질의가 들어올 때마다 IndexSearcher를 열면 성능이 크게 떨어집니다

 

QueryParser로 사용자가 입력한 검색어 파싱

루씬의 검색 메소드에 Query 객체를 인자로 지정해야 합니다

(파싱(parsing) : 사용자가 입력한 텍스트 질의를 분석해 동일한 의미의 Query 객체로 변환한다는 뜻)

검색어를 QueryParser로 파싱하면 2개의 텀 질의를 담고 있는 하나의 불리언 질의를 결과로 얻게 됩니다

QueryParser에서 텍스트 형태의 질의를 파싱하는 작업의 흐름은 아래와 같습니다

 

아래 예제코드는 텍스트 형태의 질의 2개를 파싱하고, 예상했던 Query 객체를 생성했는지 확인합니다.

그리고 결과를 받아오고 나면 첫 번째 결과 문서의 제목을 받아옵니다

 

// QueryParser를 사용해 텍스트 형태의 검색어를 Query 객체로 손쉽게 변환
public void testQueryParser() throws Exception {
    Directory dir = TestUtil.getBookIndexDirectory();
    IndexSearcher searcher = new IndexSearcher(dir);

    QueryParser parser = new QueryParser(Version.LUCENE_30,      //A
                                         "contents",                  //A
                                         new SimpleAnalyzer());       //A

    Query query = parser.parse("+JUNIT +ANT -MOCK");                  //B
    TopDocs docs = searcher.search(query, 10);
    assertEquals(1, docs.totalHits);
    Document d = searcher.doc(docs.scoreDocs[0].doc);
    assertEquals("Ant in Action", d.get("title"));

    query = parser.parse("mock OR junit");                            //B
    docs = searcher.search(query, 10);
    assertEquals("Ant in Action, " + 
                 "JUnit in Action, Second Edition",
                 2, docs.totalHits);

    searcher.close();
    dir.close();
  }

 

QueryParser 클래스의 가장 큰 목표는 사용자가 직접 타이핑한 검색어를 Query 객체로 변환하는 일입니다

변환하고 나서 IndexSearcher를 통해 일반적인 방법으로 그대로 검색하면 됩니다

 

실제 색인에 추가된 텀과 동일한 텀으로 검색 질의를 만들어야 결과를 얻을 수 있다 
(QueryParser에 알맞은 Analyzer를 지정하자)

 

QueryParser 활용

QueryParser parser = new QueryParser(Version matchVersion, String field, Analyzer analzyer)

matchVersion : 루씬 버전 (루씬 버전에 따른 하위호환성을 확보하고자 인자에 넣음)

 

 

QueryParser의 parse 메소드를 통해 간단한 질의를 얻을 수 있습니다

public Query parse(String query) throws ParseException

public Query parse(String query) throws ParseException

 

 

QueryParser 클래스로 기본 질의 처리

QueryParser 클래스는 사용자가 입력한 질의 문자열을 루씬의 Query 객체로 변환합니다

IndexSearcher 활용

 

루씬에서 검색 과정
IndexSearcher 인스턴스를 하나 생성합니다 대상 색인에서 검색할 수 있는 준비가 끝나고, search 메소드를 호출하면 검색 결과를 받아볼 수 있습니다 검색 결과로 받아온 TopDocs 객체는 점수가 가장 높은 문서를 나타냅니다

 

IndexSearcher 인스턴스를 생성하는 방법부터 알아봅시다

 

IndexSearcher 인스턴스 활용

먼저 색인처럼 Directory 클래스가 필요합니다

Directory dir = FSDirectory.open(new File("/path/to/index"));

다음으로 IndexReader 객체를 준비하고,

인자로 넣어 IndexSearcher 객체를 생성합니다

IndexReader reader = IndexReader.open(dir);
IndexSearcher searcher = new IndexSearcher(reader);

IndexSearcher의 검색 API는 Query 객체를 인자로 받아 검색한 결괄르 TopDocs 객체로 리턴합니다

 

IndexReader : 색인의 파일을 열고 기본적인 색인 수준의 API를 제공

IndexSearcher : IndexReader의 API를 활용해 검색 메소드를 제공하는 껍데기

즉, IndexReader는 자원을 많이 소모하므로 하나만 열어두고 최대한 공유해 사용하는 편이 좋습니다

다만 변경된 색인을 다시 불러올 필요가 있을 경우처럼 꼭 필요한 경우에 새로 열어줍니다

 

IndexReader 인스턴스를 새로 만들 때 자원을 많이 소모합니다 따라서 가능한 대로 IndexReader를 최대한 재사용해야 하며, 새로 만드는 일은 최소화해야 합니다

 

IndexReader는 항상 자신이 생성되던 시점의 색인을 기준으로 동작합니다

IndexReader를 생성한 이후 색인에 반영된 내용을 받아오려면 IndexReader를 새로 열어야 합니다

(IndexReader.reopen() 메소드를 사용하면 편하고, 내부적으로 필요한 자원을 최소화합니다)

reopen() 메소드는 색인에 변경된 내용이 있을 때만 새로운 IndexReader 인스턴스를 리턴합니다

 

IndexSearcher 인스턴스를 사용해 검색하면 이 인스턴스를 생성하던 시점의 색인을 대상으로 검색합니다 
따라서 검색과 동시에 색인을 변경하더라도 변경된 내용이 즉각 검색에 노출되지 않습니다 
만약 변경 사항을 검색 결과에서 보고싶다면 IndexReader를 새로 열어줍니다

 

검색 실행

IndexSearcher 인스턴스를 확보한 후, search() 메소드로 검색을 실행할 수 있습니다

내부적으로 엄청난 양의 일이 아주 빠르게 일어납니다

색인에 있는 모든 문서를 살펴보고 질의에 해당하는 문서를 찾아서 검색 결과에 포함시킵니다

마지막으로 결과 중 첫 번째 페이지에 해당하는 내용을 불러와 리턴합니다

 

TopDocs 결과 활용

search 메소드를 통해 결과로 TopDocs 객체를 받아왔습니다

TopDocs 객체를 통해 검색 결과에 포함된 실제 문서를 효율적으로 불러올 수 있습니다

검색 결과는 연관도(각 문서가 질의에 얼마나 일치하는지)에 따라 정렬됩니다

 

결과 페이지 이동

ScoreDoc 객체를 페이지 단위로 불러와야 합니다

페이지 이동 기능을 구현하는 방법으로 2가지 방법을 고려해볼 수 있는데,

 

1.처음 검색 결과를 받아올 때 아예 여러 페이지에 해당하는 결괄르 모두 받아온 후, 사용자가 해당 검색 결괄르 보는 동안 ScoreDoc 배열과 IndexSearcher 인스턴스를 보관

2.사용자가 다른 페이지로 이동할 때마다 새로 검색

 

무엇이 더 효율적일까? 2번입니다

 

ScoreDoc 배열과 IndexSearcher를 보관하려면 자원을 많이 소모하고 웹 서버에 부담입니다

매번 새로 검색해도 루씬의 검색 속도가 충분하므로 무리가 적습니다

또한, 운영체제에서 지원하는 입출력 캐시 등으로 인해 동일한 검색 질의를 다시 실행하더라도 관련 색인 파일 등이 캐시에 들어 있을 확률이 높습니다

그리고 사용자가 두 번째 페이지로 넘어가는 일도 많지 않습니다

준실시간 검색

준실시간 검색 기능을 활용하면 IndexReader를 계속 다시 열거나 새로 생성할 필요 없습니다

현재 열려있는 IndexWriter를 통해 색인에 반영되는 변경사항을 거의 실시간으로 검색 결과를 볼 수 있습니다

검색을 담당하는 부분과 IndexWriter가 같은 JVM 안에 있다면 준실시간 검색을 활용할 수 있습니다

 

실시간이 아니라 준실시간입니다 
색인의 변경 사항을 처리하고 검색에 반영하는 최소한의 시간이 필요하기 때문에 운영체제 등에서 말하는 실시간이란 용어보다 약한 의미에서 준실시간이라 부릅니다

 

보통의 준실시간 검색은 매우 빠른 시간이지만,

대량의 가비지 컬랙션 작업, 규모가 큰 세그먼트 병합 작업 등으로 인해 하드웨어 성능이 떨어지는 경우에는 반영시간이 눈에 띄게 걸릴 가능성이 있습니다

 

준실시간 검색 기능을 지원하기 전에는 IndexWriter에서 commit 메소드를 호출한 후 IndexReader를 다시 열어야 했습니다

(commit 메소드에서 저장된 모든 파일이 실제 디스크에 저장되게 sync 작업을 거쳐야 하는데 sync 작업은 메모리 버퍼에 담겨있던 내용을 모두 디스크에 보관하기 때문에 오래 걸리는 작업입니다)

 

준실시간 검색 기능을 사용하면 방금 전에 추가했지만 아직 commit하지 않은 문서까지 검색할 수 있습니다

 

public class NearRealTimeTest extends TestCase {
  public void testNearRealTime() throws Exception {
    Directory dir = new RAMDirectory();
    IndexWriter writer = new IndexWriter(dir, new StandardAnalyzer(Version.LUCENE_30), IndexWriter.MaxFieldLength.UNLIMITED);
    for(int i=0;i<10;i++) {
      Document doc = new Document();
      doc.add(new Field("id", ""+i, Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
      doc.add(new Field("text", "aaa", Field.Store.NO, Field.Index.ANALYZED));
      writer.addDocument(doc);
    }
    IndexReader reader = writer.getReader();                 // #1
    IndexSearcher searcher = new IndexSearcher(reader);      // #A

    Query query = new TermQuery(new Term("text", "aaa"));
    TopDocs docs = searcher.search(query, 1);
    assertEquals(10, docs.totalHits);                        // #B

    writer.deleteDocuments(new Term("id", "7"));             // #2

    Document doc = new Document();                           // #3
    doc.add(new Field("id",                                  // #3
                      "11",                                  // #3
                      Field.Store.NO,                        // #3
                      Field.Index.NOT_ANALYZED_NO_NORMS));   // #3
    doc.add(new Field("text",                                // #3
                      "bbb",                                 // #3
                      Field.Store.NO,                        // #3
                      Field.Index.ANALYZED));                // #3
    writer.addDocument(doc);                                 // #3
    
    IndexReader newReader = reader.reopen();                 // #4
    assertFalse(reader == newReader);                        // #5
    reader.close();                                          // #6
    searcher = new IndexSearcher(newReader);              

    TopDocs hits = searcher.search(query, 10);               // #7
    assertEquals(9, hits.totalHits);                         // #7

    query = new TermQuery(new Term("text", "bbb"));          // #8
    hits = searcher.search(query, 1);                        // #8
    assertEquals(1, hits.totalHits);                         // #8

    newReader.close();
    writer.close();
  }
}

#1 IndexWriter 인스턴스에서 아직 커밋하지 않은 IndexReader 인스턴스를 넘겨줍니다

(여기에서 IndexReader 인스턴스는 항상 읽기 전용입니다)

 

#2,3 색인의 내용을 변경하지만, 아직 커밋하지 않습니다

 

#4,5,6 IndexReader.reopen 메소드로 색인의 내용을 다시 불러옵니다

(내부적으로 writer.getReader 메소드를 다시 한 번 호출할 뿐)

IndexReader를 새로 받아왔으니 이전꺼는 닫아줍니다

 

#7,8 IndexWriter로 변경한 내용이 검색 결과에 반영됩니다

 

가장 핵심적인 메소드는 바로 IndexWriter.getReader 메소드입니다

 

현재 버퍼에 쌓여있던 모든 변경 사항을 Directory 안에 반영하고,

해당 변경 사항을 포함하는 IndexReader 인스턴스를 생성해줍니다

이후 추가적으로 변경했다면 IndexReader의 reopen 메소드를 호출합니다

그리고 이전 IndexReader는 닫아줍니다

reopen() 메소드는 기존에 열려있던 변경 사항이 없는 색인 파일은 그대로 물려받기 때문에 매우 효율적으로 동작합니다

즉, 최근에 인스턴스를 생성한 시점 이후에 새로 생성된 색인 파일만 추가적으로 불러옵니다

(대부분 1초미만의 시간이 걸림)

 

 

이 글은 “Lucene In Action” 책 내용을 요약한 글입니다.

만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

 

댓글