색인에서는 4가지 주제를 통해 알아보겠습니다
1. 기본적인 색인 작업
2. 색인 과정에서 문서나 필드에 중요도 지정
3. 날짜, 숫자 필드, 정렬 가능한 필드
4. 고급 색인 기법
색인 작업은 단 하나의 목표를 갖고 있다는 점을 기억하자!
→ 바로 사용자에게 제공할 검색 서비스의 품질입니다
즉, 거의 모든 검색 애플리케이션에서 색인 과정의 세부적인 설정 1~2가지 보다는 사용자가 원하는 검색 기능이 훨씬 중요합니다
루씬 데이터 모델
문서와 필드
문서란 루씬에서 색인과 검색 작업을 진행할 때 한 건이라고 부를 수 있는 단위입니다
문서에는 1개 이상의 필드를 가집니다
검색 시점에 특정 필드의 값을 검색할 수 있는데
예를 들어 'name:jack'이라고 검색하면, name이란 필드에 jack이란 단어가 들어있는 문서를 찾아달라는 뜻입니다
루씬이 각 필드를 기준으로 처리하는 3가지 작업
1. 필드의 내용을 색인할 것인지 하지 않을 것인지 여부
필드의 내용을 검색하려면 반드시 색인해야합니다
내용을 색인하면 텍스트 분석 절차를 걸쳐 토큰을 뽑아내고, 각 토큰을 색인에 추가합니다
2. 필드의 내용을 색인하는 경우
필드마다 추가적으로 term vector를 저장하게 설정할 수 있습니다
해당 필드에서 생성된 term vector를 모두 모으면 해당 필드 내부의 역파일 색인이라고 볼 수 있고,
term vertor를 통해 필드의 토큰을 모두 찾아볼 수 있습니다
또한, 특정 문서와 비슷한 문서를 찾아 주는 등의 고급 기능도 구현할 수 있습니다
3. 색인 여부와 관계없이 필드의 내용을 저장할 것인지 여부
필드의 내용을 저장하게 설정하면 분석 절차를 거치지 않은 텍스트가 그대로 색인에 저장됩니다
내용을 사용자에게 그대로 보여줄 필요가 있는 경우 사용하면 좋습니다
루씬 vs 데이터베이스
루씬의 색인과 데이터베이스의 가장 큰 차이점 중 하나는
루씬은 데이터베이스처럼 스키마를 정의하지 않아 매우 유연합니다
알아두자! 색인에서 문서를 가져오는 경우 색인에 저장하라고 설정했던 필드만 문서에 들어옵니다
반면, 색인했지만 저장하지 않은 필드는 검색 결과에 포함되지 않습니다
유연한 스키마
데이터베이스와 달리 루씬에서는 별도의 스키마가 존재하지 않습니다
즉, 색인에 추가하려고 준비하는 각 문서가 서로 완전히 다른 필드 구조를 가지더라도 문제가 없죠
루씬은 색인에 스키마를 지정하지 않기 때문에 하나의 색인에 2개 종류 이상의 문서를 함께 넣을 수 있습니다
예를 들어 판매 중인 상품의 이름과 가격 등을 필드에 넣은 문서와,
사람의 이름과 나이 성별 등을 넣은 문서를 하나의 색인에 함께 넣어둘 수 있습니다
또한, 검색 결과에는 포함시키지 않을 '메타 문서'를 색인에 넣어 둘 수도 있죠
비정규화
두 번째로 루씬과 데이터베이스가 서로 다른 점은 루씬의 색인에 추가하려는 정보는 모두 텍스트로 변환하고 비정규화해야 한다는 점입니다
정규화 vs 비정규화
정규화 : 관계형 데이터베이스의 데이터에 존재하는 중복성을 최소하기 위해 수행되는 프로세스 (주로 큰 테이블을 중복성이 적은 작은 테이블로 분할)
비정규화 : 정규화 프로세스의 역 프로세스로 중복 데이터를 추가하거나 데이터를 그룹화하여 단일 로우로 만드는 프로세스 (중복 데이터가 추가되지만 성능이 최적화)
색인할 때는 항상 루씬이 지원하는 문서 표현 방식에 맞춰 원본 파일을 적절하게 변형해야 합니다
루씬의 문서는 완전히 1차원적입니다
내용을 중첩시킬 수도 없고 조인 연산도 불가능합니다
색인 절차
색인 작업은 크게 3가지 기능 단위로 구분할 수 있습니다
1. 원본 문서에서 텍스트 추출
색인 작업을 진행할 때, 가장 먼저 원본 문서 파일에서 텍스트를 뽑아내고, 루씬의 Field 객체를 만들어 최종적으로 Document 객체를 생성합니다
2. 텍스트를 분석
Field 객체에 담겨있는 텍스트를 분석 절차를 거쳐 일련의 토큰 스트림으로 변환합니다
3. 색인에 추가
마지막으로 세그먼트 구조의 색인에 토큰이 하나씩 추가됩니다
위 3가지 단계를 조금 더 자세히 알아보자
1. 텍스트 추출과 문서 생성
루씬으로 원본 문서를 색인하려면 먼저 텍스트를 추출하고 Document 객체를 생성해야 합니다
(루씬은 텍스트만 처리할 수 있습니다)
하지만, 원본 문서에서 텍스트를 추출하는 작업이 간단하지 않습니다
PDF, HTML 등에서 텍스트 정보를 뽑아낼 방법을 찾아야하고, 그 텍스트로 루씬의 Field 객체와 Document 객체를 구성해야 합니다
문서 파일에서 텍스트를 추출하는 방법에 대해서는 나중에 티카 프레임워크를 소개하며 자세히 설명하겠습니다
2. 분석
Field 객체에 내용을 담고 루씬 Document 객체를 생성해 추가하고 나면,
이제 IndexWriter 객체의 addDocument 메소드를 호출해서 루씬이 색인하게 문서를 넘겨줍니다
루씬은 Document 객체에 포함된 텍스트를 모두 일련의 토큰으로 분리하고,
설정에 따라 몇 가지 추가 작업을 진행합니다
(예를 들어 대소문자 구분, 불용어 제거 등)
같은 텍스트를 분석하더라도 여러 필터를 조합해 매우 다양한 결과를 얻을 수 있습니다
이렇게 분석 과정을 거치면 최종적으로 일련의 토큰이 생성됩니다
3. 색인에 토큰 추가
입력된 텍스트를 모두 분석하면 이제 토큰을 색인에 추가할 차례입니다
루씬은 입력된 텍스트를 토큰으로 변환해 역파일 색인(Inverted index) 구조에 저장합니다
역파일 색인은 원하는 검색어를 빠르게 조회할 수 있으면서 디스크 공간을 매우 효율적으로 사용하는 장점을 가지고 있습니다
역파일 색인은 문서 단위가 아닌, 정렬된 토큰을 기준으로 조회합니다 마치 종이책 뒤에 제공되는 색인에서 원하는 단어를 찾아 해당 페이지로 바로 이동해 찾아보는 것과 같은 개념입니다
세그먼트에 대해 알아볼까요?
루씬의 색인 하나는 다수의 세그먼트로 구성됩니다
색인 세그먼트
루씬 색인은 항상 1개 이상의 세그먼트로 구성됩니다
각 세그먼트는 개별적인 색인이며, 전체 색인에 들어있는 문서 중 일정량을 담고 있습니다
IndexWriter에서 추가하거나 삭제한 문서를 버퍼에 쌓아 두고 있다가 플러시(Flush)하면 새로운 세그먼트가 생성됩니다
그리고 검색할 때는 각 세그먼트를 하나씩 조회한 후 결과를 하나로 합해 넘겨줍니다
하나의 세그먼트는 여러 종류의 파일로 구성됩니다
각 파일은 _X.<확장자>의 형태로 이름이 붙어 있고, X부분이 해당 세그먼트의 이름입니다
그리고 각 파일이 담당하는 기능에 따라 확장자를 지정합니다
(예를 들어 텀 벡터를 담고 있는 파일, 저장된 필드를 담고 있는 파일, 역파일 색인을 담고 있는 파일 등)
통합 색인 형식을 사용하고 있다면 세그먼트의 모든 파일이 _X.cfs 파일 안에 깔끔하게 들어갑니다
(루씬의 기본 설정이 통합 색인 형식이고, 바꾸고 싶으면 IndexWriter.setUseCompoundFile 메소드로 설정을 변경할 수 있습니다)
통합 색인 형식을 사용하면 물리적으로 세그먼트마다 하나의 파일만 사용하기 때문에 성능에 약간의 영향이 있을 수 있지만, 운영체제 차원에서 파일 개방 개수를 줄이는 효과가 있습니다
segments_<N> 이라는 이름의 특별한 세그먼트 파일이 있는데,
해당 색인을 구성하는 모든 세그먼트에 대한 참조를 보관하고 있습니다
따라서 루씬이 색인을 열 때 가장 먼저 읽는 파일이고, 매우 중요합니다
<N>은 흔히 '세대(generation)'라고 부르며, 색인에 변경 사항이 반영될 때마다 하나씩 증가하는 정수 값입니다
색인의 사용이 늘어날 수록 자연적으로 점점 더 많은 세그먼트가 생성되고,
특히 IndexWriter 클래스를 자주 사용하면 세그먼트의 개수가 빠르게 늘어납니다
하지만, 아무리 많아지더라도 문제 없습니다
IndexWriter 클래스가 주기적으로 세그먼트 몇 개를 선택해 하나의 새로운 세그먼트로 병합하고, 병합된 기존 세그먼트는 삭제합니다
병합에 관련된 MergePolicy, MergeScheduler 클래스는 고급 주제에 속하며 나중에 다시 알아보겠습니다
기본 색인 작업
// 아래의 예제는 루씬 8.3.0 버전으로 작성되었습니다
// gradle add lucene dependency
compile group: 'org.apache.lucene', name: 'lucene-highlighter', version: '8.3.0'
compile group: 'org.apache.lucene', name: 'lucene-queryparser', version: '8.3.0'
compile group: 'org.apache.lucene', name: 'lucene-analyzers-common', version: '8.3.0'
이제 실제 코드를 보면서 루씬에서 제공하는 문서 추가, 변경, 삭제 등의 API 동작을 살펴보자
public static final String INDEX_PATH = "/Users/82109/IdeaProjects/lucene/src/main/resources/file";
protected String[] ids = {"1", "2"};
protected String[] unindexed = {"Netherlands", "Italy"};
protected String[] unstored = {"Amsterdam has lots of bridges",
"Venice has lots of canals"};
protected String[] text = {"Amsterdam", "Venice"};
private Directory directory;
private IndexSearcher getIndexSearcher() throws IOException {
FSDirectory directory = FSDirectory.open(Paths.get(INDEX_PATH));
DirectoryReader reader = DirectoryReader.open(directory);
return new IndexSearcher(reader);
}
private FieldType storedTokenizedType() {
FieldType storedTokenizedType = new FieldType();
storedTokenizedType.setStored(true);
storedTokenizedType.setTokenized(true);
return storedTokenizedType;
}
private FieldType storedNoTokenizedType() {
FieldType storedTokenizedType = new FieldType();
storedTokenizedType.setStored(true);
storedTokenizedType.setTokenized(false);
return storedTokenizedType;
}
private IndexWriter getWriter() throws IOException {
IndexWriterConfig config = new IndexWriterConfig(new WhitespaceAnalyzer());
return new IndexWriter(directory, config);
}
색인에 문서 추가
예제) 색인에 문서 추가
@BeforeEach
void setUp() throws IOException {
// file 폴더 하위 삭제 후, 인덱싱
File deleteFolder = new File(INDEX_PATH);
File[] deleteFolderList = deleteFolder.listFiles();
for (int j = 0; j < deleteFolderList.length; j++) deleteFolderList[j].delete();
directory = FSDirectory.open(Paths.get(INDEX_PATH));
IndexWriter writer = getWriter();
for (int i = 0; i < ids.length; i++) { //3
Document doc = new Document();
doc.add(new Field("id", ids[i], storedNoTokenizedType()));
doc.add(new Field(ountry", unindexed[i], storedTokenizedType()));
doc.add(new TextField("contents", unstored[i], Field.Store.NO));
doc.add(new TextField("city", text[i], Field.Store.YES));
writer.addDocument(doc);
}
writer.close();
}
색인에서 문서 조회
예제) 색인에서 문서 조회
@Test
@DisplayName("간단한 단일 텀 질의 생성 후 조회")
void simpleQuery() throws IOException {
IndexSearcher searcher = getIndexSearcher();
Term t = new Term("city", "Amsterdam");
Query query = new TermQuery(t);
TopDocs foundDocs = searcher.search(query, 1);
System.out.println(foundDocs.totalHits);
for (ScoreDoc sd : foundDocs.scoreDocs) {
Document d = searcher.doc(sd.doc);
System.out.println("Doc : " + sd.doc + " :: " + d.get("city"));
}
}
// Doc : 0 :: Amsterdam
색인에서 문서 삭제
예제) 색인에서 문서 삭제
@Test
@DisplayName("2개 색인 후 검증 && 삭제하고 다시 검증")
void delete() throws IOException {
IndexSearcher indexSearcher = getIndexSearcher();
assertEquals(ids.length, indexSearcher.getIndexReader().maxDoc()); // 2
IndexWriter writer = getWriter();
writer.deleteAll();
writer.commit();
IndexSearcher indexSearcherAfterDelete = getIndexSearcher();
assertEquals(0, indexSearcherAfterDelete.getIndexReader().maxDoc()); // 0
}
색인의 문서 변경
예제) 색인의 문서 변경
@Test
@DisplayName("city : Amsterdam를 서울로 업데이트")
void update() throws IOException {
IndexSearcher searcher = getIndexSearcher();
Term t = new Term("city", "Amsterdam");
Query query = new TermQuery(t);
TopDocs foundDocs = searcher.search(query, 1);
for (ScoreDoc sd : foundDocs.scoreDocs) {
Document d = searcher.doc(sd.doc);
System.out.println("Doc : " + sd.doc + " id :" +d.get("id")+ " city : " + d.get("city"));
}
Document doc = new Document();
doc.add(new Field("id", "1", storedNoTokenizedType()));
doc.add(new Field("country", "한국", storedTokenizedType()));
doc.add(new TextField("contents", "새로 추가", Field.Store.NO));
doc.add(new TextField("city", "서울", Field.Store.YES));
IndexWriter writer = getWriter();
Term newTerm = new Term("city", "Amsterdam");
writer.updateDocument(newTerm, doc);
writer.commit();
writer.close();
IndexSearcher searcher2 = getIndexSearcher();
Query newQuery = new TermQuery(new Term("city", "서울"));
TopDocs foundDocs2 = searcher2.search(newQuery, 1);
for (ScoreDoc sd : foundDocs2.scoreDocs) {
Document d = searcher2.doc(sd.doc);
System.out.println("Doc : " + sd.doc + " id :" +d.get("id")+ " city : " + d.get("city"));
}
}
// Doc : 0 id :1 city : Amsterdam
// Doc : 0 id :1 city : 서울
필드별 설정
Field 클래스
Document의 일부분으로 색인에 문서를 추가할 때 가장 핵심이 되는 클래스입니다
Field는 IndexableField 인터페이스의 구현체입니다
public class Field implements IndexableField {
/**
* Field's type
*/
protected final IndexableFieldType type;
/**
* Field's name
*/
protected final String name;
/** Field's value */
protected Object fieldsData;
필드의 원문 저장 관련 설정
Store.YES
필드의 텍스트를 색인에 그대로 저장합니다
색인의 크기에 민감한 애플리케이션이라면 텍스트가 많은 필드는 색인에 저장하지 않는 편이 좋습니다
필드의 텍스트를 색인에 저장하지 않습니다
필드의 내용을 검색해야 하지만, 내용이 너무 길고 결과 화면에 보여줄 필요가 없을 경우 사용합니다
텀 벡터 관련 설정
텀 벡터는 검색 결과에서 텀과 함께 도큐먼트의 특정 필드에 출현한 단어와 단어의 출현 횟수 등의 부가적인 정보를 가진 객체입니다
텀 벡터는 텀의 빈도수(Term frequency), 텀의 위치(Term position), 텀의 시작위치(Term offset)를 가지고 있습니다
텀 벡터는 색인될 필드와 저장된 필드의 특징을 고루 갖고 있습니다
텀 벡터는 분석기에서 추출한 실제 개별 텀을 저장하기 때문에 필드마다 별도로 텀을 뽑아낼 수 있고,
문서 안에서 각 텀의 빈도수를 계산하거나 텀의 사전 순서로 정렬하는 등의 작업을 할 수 있습니다
StringField
토큰화 과정이 없다는게 특징입니다
단일 토큰으로 역색인 구조에 저장됩니다
TextField
텍스트 타입의 필드를 색인하는 클래스입니다
색인이나 검색 시 텍스트는 분석기에 의해 토큰화됩니다
StoredField
숫자 타입의 경우 사용합니다 (int, float, long, double)
Date 타입
DateTools 클래스를 사용해 변환하여 String 타입으로 저장해야 합니다
문서와 필드 중요도
루씬의 구 버전에서는 색인 시 도큐먼트 필드에 부스팅을 적용 가능했으나 최근 삭제되었습니다 (루씬 6.5에서 deprecated, 7에서는 removed) 부스트 값을 변경하면 모든 도큐먼트를 다시 색인해야 했기 때문입니다
norm?
Norm은 도큐먼트의 길이에 부스팅을 설정해 점수 계산에 사용하는 값의 하나입니다
검색 시 쿼리와 매치되는 도큐먼트로 점수가 계산하기 때문에 일반적으로는 Norm보다는 다른 요소가 점수 계산에 더 영향을 끼칩니다
Norm은 색인될 때 계산되고, 색인 시 도큐먼트와 함께 저장되고 쿼리 시 검색 성능을 높여줍니다
짧은 필드에 가중치를 줍니다
이 글은 “Lucene In Action” 책 내용을 요약한 글입니다.
만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.
'Lucene > Lucene In Action' 카테고리의 다른 글
[루씬 인 액션] 6. 분석기(1) - 활용 및 구조 (0) | 2020.12.15 |
---|---|
[루씬 인 액션] 5. 검색(2) - Score & Query (0) | 2020.12.01 |
[루씬 인 액션] 4. 검색(1) - IndexSearcher (0) | 2020.11.29 |
[루씬 인 액션] 3. 색인(2) - 고급 색인 기법 (0) | 2020.11.18 |
[루씬 인 액션] 1. 루씬(Lucene)의 개념 및 구조 (0) | 2020.11.08 |
댓글