Apache Lucene
고친 사람 github-actions[bot]
Apache Lucene 은 자바 프로그램 안에 넣어 글을 빠르게 찾게 해 주는 검색 라이브러리입니다. 글을 미리 낱말 단위로 정리해 두었다가 낱말이 들어오면 그 낱말이 든 글을 곧바로 찾아 줍니다. 찾은 글에는 얼마나 잘 맞는지 점수를 매겨 순서를 정합니다. Elasticsearch 같은 검색 서버가 속에 이 라이브러리를 품고 있습니다.
쉽고 빠른 이해
글 더미에서 낱말 하나로 원하는 글을 골라 주는 부품입니다. 상품 설명 수만 건 가운데 그 낱말이 든 것을 찾아 잘 맞는 순서로 돌려줍니다.
왜 이렇게 하나. 데이터베이스에서 글 속 낱말을 찾으면 모든 행을 처음부터 끝까지 읽게 됩니다. 글이 늘수록 그만큼 느려집니다. 어느 글이 더 잘 맞는지도 알려 주지 않습니다.
어떻게 도나.
- 글을 넣을 때 낱말로 쪼갭니다. 낱말마다 그 낱말이 든 글의 번호를 적어 둡니다.
- 찾을 때는 그 목록에서 번호를 꺼내 옵니다.
- 낱말이 얼마나 자주, 얼마나 드물게 나오는지로 점수를 매겨 줄을 세웁니다.
대가. 한 기계 안에서만 돌아갑니다. 여러 서버로 나누고 네트워크로 부르게 하려면 다른 제품을 위에 얹어야 합니다. 이미 넣은 글을 고치면 지우고 다시 넣는 셈이라 쓰기가 무겁습니다.
언제 쓰나. 자바 애플리케이션 하나 안에서 검색을 직접 다룰 때 씁니다. 검색을 서버 여러 대로 나눠야 하면 Lucene 을 품은 검색 서버를 씁니다.
상세
Lucene 은 Apache 소프트웨어 재단이 만드는 오픈 소스 검색 라이브러리입니다. 라이브러리는 프로그램에 끼워 넣고 함수처럼 불러 쓰는 코드 묶음입니다. 따로 띄우는 서버가 아니므로 Lucene 을 넣은 프로그램이 곧 검색 엔진이 됩니다.
Lucene 이 푸는 일은 전문 검색입니다. 전문 검색은 글의 본문 전체를 대상으로 낱말을 찾는 검색입니다. 이 검색을 빠르게 하려면 글을 찾기 쉽게 미리 정리해 둬야 합니다. 그렇게 정리해 둔 것을 색인이라고 부릅니다.
색인은 영어로 index 라서 데이터베이스의 인덱스와 같은 낱말입니다. 둘 다 전부 읽지 않으려고 미리 정리해 둔 것입니다. 여기서는 Lucene 쪽을 색인, 데이터베이스 쪽을 인덱스라고 부릅니다.
이 절은 짧은 글 세 편을 색인에 넣고 fox 라는 낱말로 찾는 장면 하나를 따라갑니다. 그 장면 위에서 글을 쪼개는 단계, 찾는 목록, 점수, 파일이 쌓이는 방식을 차례로 봅니다.
데이터베이스로 찾을 때 곤란한 점
글 속 낱말을 데이터베이스에서 찾으려면 흔히 LIKE '%fox%' 같은 조건을 씁니다. 이 조건은 fox 앞에 무엇이든 올 수 있다는 뜻이라 인덱스를 타지 못합니다. 그래서 행을 처음부터 끝까지 읽는 풀 테이블 스캔이 일어납니다. 글이 늘수록 느려집니다.
속도만 문제가 아닙니다. 이 조건은 firefox 처럼 낱말 일부에 fox 가 든 글까지 걸어 옵니다. 찾은 글 가운데 어느 것이 더 잘 맞는지도 알려 주지 않습니다. Lucene 은 글을 넣을 때 미리 정리해 두는 방식으로 이 셋을 한꺼번에 풉니다.
역색인
Lucene 이 글을 정리해 두는 모양은 역색인입니다. 역색인은 낱말마다 그 낱말이 든 글의 번호를 적어 둔 목록입니다. 글에서 낱말을 찾는 대신, 낱말에서 글을 찾도록 방향을 뒤집어 둔 것입니다.
책 맨 뒤의 찾아보기를 떠올리면 됩니다. 찾아보기에서 낱말 하나를 찾으면 그 낱말이 나오는 쪽 번호가 적혀 있습니다. 책을 첫 쪽부터 넘길 필요가 없습니다.
Lucene 은 색인에 넣는 글 한 편을 문서라고 부릅니다. 문서마다 번호가 붙습니다. 역색인에 적히는 것이 이 번호입니다.
문서 세 건을 넣었다고 해 봅시다. 1번은 Quick brown fox, 2번은 Lazy brown dog, 3번은 Fox chases dog 입니다. 낱말을 소문자로 맞춰 정리하면 역색인은 아래와 같습니다.
| 낱말 | 든 문서 |
|---|---|
| brown | 1, 2 |
| chases | 3 |
| dog | 2, 3 |
| fox | 1, 3 |
| lazy | 2 |
| quick | 1 |
표에서 fox 를 찾으면 한 줄만 읽고 1번과 3번을 얻습니다. 글 전체를 훑지 않고 그 낱말의 줄만 읽으므로 글이 늘어도 찾는 일이 크게 무거워지지 않습니다. 한 낱말에 딸린 문서 번호 목록을 포스팅 리스트라고 부릅니다.
앞 절의 firefox 문제도 여기서 풀립니다. firefox 는 따로 한 줄을 차지하는 다른 낱말이라 fox 줄에 섞이지 않습니다.
분석기
표의 낱말은 저절로 생기지 않습니다. 글을 넣을 때 분석기가 글을 낱말로 쪼개고 다듬습니다.
쪼개어 나온 낱말 하나하나를 토큰이라고 부릅니다. 역색인의 한 줄은 토큰 하나입니다.
분석기 안에서는 아래 단계가 차례로 돌아갑니다.
- 토크나이저가 글을 토큰으로 자릅니다.
Fox chases dog는Fox·chases·dog가 됩니다. - 토큰 필터가 토큰을 다듬습니다. 소문자로 바꾸는 필터를 거치면
Fox가fox가 됩니다. - 필요하면 필터를 더 붙입니다.
chases를chase로 줄여 낱말의 뿌리만 남기는 필터가 있습니다. 이 일을 어간 추출이라고 합니다. the처럼 어느 글에나 나와 찾는 데 쓸모없는 낱말을 불용어라고 합니다. 불용어를 빼는 필터도 붙일 수 있습니다.
찾을 때도 같은 분석기를 써야 합니다. 사용자가 Fox 를 입력했다고 해 봅시다. 소문자로 바꾸지 않고 찾으면 표에 없는 낱말이라 아무것도 안 나옵니다.
한국어는 띄어쓰기만으로 자르면 곤란합니다. 검색을 과 검색이 가 다른 낱말이 되어 서로를 못 찾습니다. 그래서 조사와 어미를 떼어 내는 형태소 분석기를 붙입니다. Lucene 에는 Nori 라는 한국어 형태소 분석기가 모듈로 들어 있습니다.
문서와 필드
문서 한 건은 이름 붙은 값 여러 개를 묶은 것입니다. 그 값 하나를 필드라고 합니다. 앞의 세 문서는 본문 필드 하나만 둔 문서입니다. 상품이라면 제목과 본문이 저마다 필드가 됩니다.
필드마다 다루는 방식을 고릅니다. 본문처럼 낱말로 찾을 글은 분석기를 거쳐 역색인에 들어갑니다. 상품 번호처럼 한 덩어리로 맞춰 볼 값은 쪼개지 않고 넣습니다. 결과 화면에 원문을 보여 주려면 원문도 따로 보관하라고 표시합니다.
색인하고 찾는 코드
앞의 세 편을 넣고 fox 로 찾는 코드입니다. 윗부분이 색인이고 아랫부분이 검색입니다.
Directory dir = FSDirectory.open(path);
IndexWriterConfig cfg =
new IndexWriterConfig(new StandardAnalyzer());
IndexWriter w = new IndexWriter(dir, cfg);
for (String text : texts) { // 글 세 편
Document doc = new Document();
doc.add(new TextField("body", text, Store.YES));
w.addDocument(doc);
}
w.commit();
DirectoryReader r = DirectoryReader.open(dir);
IndexSearcher s = new IndexSearcher(r);
Query q = new TermQuery(new Term("body", "fox"));
TopDocs top = s.search(q, 10);
int n = top.scoreDocs.length; // 2
맨 앞은 준비입니다. FSDirectory 는 색인 파일을 둘 디스크 폴더를 가리킵니다. StandardAnalyzer 는 Lucene 이 기본으로 주는 분석기입니다. 글을 낱말로 자르고 소문자로 바꿉니다.
IndexWriter 는 색인에 문서를 넣는 도구입니다. TextField 는 분석기를 거칠 글 필드입니다. Store.YES 는 원문도 보관하라는 표시입니다. commit() 을 불러야 넣은 문서가 디스크에 확정됩니다.
아랫부분의 DirectoryReader 가 디스크의 색인을 엽니다. IndexSearcher 가 그 색인을 읽어 찾습니다. search(q, 10) 은 잘 맞는 문서를 10건까지 TopDocs 에 담아 돌려줍니다. 그 안의 scoreDocs 가 찾은 문서의 배열입니다. 길이가 2 이니 1번과 3번 두 건입니다.
Term 은 필드 이름과 낱말 한 쌍입니다. new Term("body", "fox") 는 body 필드의 fox 줄을 가리킵니다. TermQuery 는 이 한 쌍으로 찾는 질의입니다.
TermQuery 는 분석기를 거치지 않습니다. 받은 낱말을 바꾸지 않고 역색인에서 그 줄을 찾습니다. 그래서 소문자 fox 를 넘겼습니다. 사용자가 친 검색어를 받을 때는 분석기를 거쳐 질의를 만드는 QueryParser 를 씁니다.
코드 어디에도 네트워크 호출이 없습니다. 검색은 이 프로그램의 메모리와 디스크 안에서 끝납니다.
점수 매기기
fox 가 든 문서가 여럿이면 어느 것을 먼저 보여 줄지 정해야 합니다. Lucene 은 문서마다 관련도 점수를 매겨 높은 순으로 돌려줍니다. 위 코드의 TopDocs 가 점수 순으로 줄 선 결과입니다.
점수는 세 가지를 봅니다.
| 보는 것 | 점수에 주는 영향 |
|---|---|
| 찾는 낱말이 그 문서에 나온 횟수 | 많을수록 오른다 |
| 그 낱말이 든 문서가 전체에서 얼마나 흔한가 | 흔할수록 낱말의 무게가 낮다 |
| 문서 길이 | 같은 횟수라면 짧을수록 오른다 |
앞의 장면에서는 1번과 3번이 같은 점수를 받습니다. 둘 다 fox 가 한 번 나오고 세 낱말 길이라 표의 세 줄이 모두 같기 때문입니다.
둘째 줄이 필요한 까닭은 흔한 낱말이 문서를 가려내지 못하기 때문입니다. brown 처럼 여러 문서에 나오는 낱말보다 chases 처럼 한 문서에만 나오는 낱말이 그 문서를 더 잘 짚어 냅니다.
이 셋을 식 하나로 묶은 것이 BM25(Best Matching 25)입니다. Lucene 은 기본으로 이 식으로 점수를 매깁니다. 셈에 쓰는 횟수와 문서 길이는 글을 넣을 때 역색인에 함께 적어 둡니다. 찾을 때 글을 다시 읽지 않고 점수를 낼 수 있는 까닭입니다.
세그먼트
Lucene 은 색인을 파일 하나에 몰아 두지 않습니다. 문서를 넣으면 먼저 메모리에 모았다가 어느 정도 차면 작은 색인 한 벌을 디스크에 씁니다. 이 한 벌을 세그먼트라고 합니다. 세그먼트는 한 번 쓰면 고치지 않습니다.
고치지 않으니 문서를 지울 때는 세그먼트 안에 지움 표시만 남깁니다. 검색은 표시된 문서를 건너뜁니다. 문서를 고치는 일은 옛 문서에 지움 표시를 하고 새 문서를 넣는 일입니다.
고치지 않는 파일이라 얻는 것이 있습니다. 검색 여러 개가 잠금 없이 같은 파일을 동시에 읽습니다. 파일이 안 바뀌므로 운영체제가 한 번 읽은 내용을 메모리에 오래 붙들어 둘 수 있습니다.
세그먼트가 늘면 검색이 매번 여러 파일을 돌아야 해서 느려집니다. 그래서 Lucene 은 뒤에서 작은 세그먼트 몇 개를 큰 세그먼트 하나로 합칩니다. 이 병합 때 지움 표시가 된 문서가 비로소 빠지고 디스크 공간이 돌아옵니다.
flowchart TD
subgraph BEFORE["병합 전"]
S1["세그먼트 1 · 문서 1 2 3"]
S2["세그먼트 2 · 문서 4 5 · 4 는 지움 표시"]
end
BEFORE --> M["병합"]
M --> S3["세그먼트 3 · 문서 1 2 3 5"]
그림에서 4번 문서는 지움 표시를 단 채 세그먼트 2 에 남아 있다가 병합에서 빠집니다.
넣은 문서가 보이는 때
DirectoryReader 는 연 순간의 세그먼트만 봅니다. 그 뒤에 넣은 문서는 DirectoryReader 를 다시 열어야 보입니다. Elasticsearch 에서 문서를 넣고 잠시 뒤에야 검색에 잡히는 것도 이 다시 열기를 주기적으로 하기 때문입니다.
세그먼트를 디스크에 썼다고 확정된 것은 아닙니다. 디스크에서 색인을 열면 Lucene 은 마지막 커밋이 남긴 세그먼트 목록만 읽습니다. commit() 은 새로 쓴 세그먼트가 디스크에 확실히 기록되기를 기다린 뒤 이 목록에 올리는 일입니다. 목록에 오르지 못한 세그먼트는 다시 열 때 버려집니다.
IndexWriter 를 넘겨 DirectoryReader 를 열면 커밋 전 문서도 보입니다. 이때는 커밋 목록 대신 IndexWriter 가 쥔 세그먼트를 읽습니다. 커밋을 기다리지 않고 넣은 문서를 곧 보이게 하는 이 방식을 근실시간 검색이라고 부릅니다.
하지만 commit() 을 부르기 전에 프로그램이 죽으면 그사이 넣은 문서는 사라집니다. 기록을 기다리는 만큼 커밋은 느립니다. 문서마다 커밋하면 넣기가 느려집니다. 드물게 커밋하면 죽었을 때 잃는 양이 커집니다.
포기한 것
Lucene 은 한 기계 안의 검색만 맡습니다. 요청을 받는 네트워크 입구가 없어서 다른 언어로 짠 서비스는 직접 부를 수 없습니다.
색인을 여러 서버로 나눠 담는 샤딩을 하지 않습니다. 같은 색인의 사본을 다른 서버에 두는 복제도 하지 않습니다. 그래서 그 기계가 죽으면 색인을 쓸 수 없습니다.
이 빈칸을 채운 것이 검색 서버들입니다. Elasticsearch · OpenSearch · Apache Solr 는 Lucene 위에 요청 입구, 여러 서버로 나눠 담기, 사본 관리를 얹었습니다. 기계마다 나눠 가진 색인 조각 하나하나가 Lucene 색인 하나입니다.
flowchart TD
subgraph SERVER["검색 서버가 얹은 층"]
A["요청 입구"]
B["나눠 담기 · 사본 관리"]
end
subgraph M1["기계 1"]
P1["조각 1 · Lucene 색인"]
R2["조각 2 사본 · Lucene 색인"]
end
subgraph M2["기계 2"]
P2["조각 2 · Lucene 색인"]
R1["조각 1 사본 · Lucene 색인"]
end
SERVER --> M1
SERVER --> M2
그림에서 상자 넷이 저마다 Lucene 색인 하나입니다. 기계 하나가 죽어도 다른 기계에 그 조각의 사본이 남습니다.
데이터베이스 기능도 내려놓았습니다. 표 사이를 잇는 조인 같은 관계형 기능이 없습니다. 그래서 원본은 데이터베이스에 두고 Lucene 색인은 찾기용 사본으로 두는 구성이 흔합니다.
쓰기도 가볍지 않습니다. 필드 하나만 바꿔도 대개 문서 전체를 다시 분석해 넣습니다. 지운 문서는 병합 전까지 디스크를 차지합니다.
쓰는 곳과 안 쓰는 곳
Lucene 을 가장 자주 만나는 길은 검색 서버를 거치는 것입니다. Elasticsearch 로 로그를 찾거나 상품을 검색하면 그 아래에서 Lucene 이 돌아갑니다. 검색 서버의 설정과 지표에 세그먼트·병합·분석기가 나오는 것도 이 때문입니다.
Lucene 을 직접 쓰는 것은 자바 애플리케이션 하나 안에서 검색을 끝내고 싶을 때입니다. 따로 서버를 띄우고 관리할 필요가 없습니다. 대신 색인 파일의 백업과 여러 서버에 걸친 운영은 애플리케이션이 떠안습니다.
맞지 않는 경우도 있습니다. 주문 번호나 이메일처럼 값이 딱 맞는 것을 찾는 일은 데이터베이스의 인덱스로 충분합니다. 검색을 여러 서버로 나눠야 할 만큼 크면 Lucene 을 직접 쓰기보다 검색 서버를 고릅니다.
관련 항목
Lucene 을 이루는 구성 요소
역색인 · 포스팅 리스트 · 분석기 · 토크나이저 · 토큰 필터 · 세그먼트 · 세그먼트 병합 · 문서 · 필드
Lucene 이 글을 쪼개고 다듬는 처리 단계
토큰화 · 토큰 · 형태소 분석 · Nori · 어간 추출 · 표제어 추출 · 불용어 · 동의어 사전
Lucene 이 순위를 매기는 계산 방식
관련도 · BM25 · TF-IDF · 벡터 공간 모델 · 랭킹
Lucene 이 받는 질의의 종류
불리언 검색 · 구문 검색 · 퍼지 검색 · 와일드카드 검색 · 접두사 검색 · 벡터 검색
Lucene 이 색인을 쓰고 검색에 보이게 하는 방식
증분 색인 · 근실시간 검색 · 커밋 · 불변성 · 페이지 캐시
Lucene 을 품은 검색 서버
Elasticsearch · OpenSearch · Apache Solr · 검색 엔진 라이브러리
Lucene 이 맡지 않아 검색 서버가 얹는 기능
Lucene 과 견주는 데이터베이스 쪽 검색
데이터베이스 · 인덱스 · B-tree · LIKE · 풀 테이블 스캔
Lucene 과 같은 일을 두고 겨루는 검색 수단
Meilisearch · Typesense · Vespa · Tantivy · Xapian · PostgreSQL 전문 검색 · FTS5
Lucene 이 속하는 상위 분류
다른 이름: Lucene · 루씬 · 아파치 루씬