전문 검색 인덱스
문서의 내용 전체를 인덱스화해서 특정 키워드가 포함된 문서를 검색하는 전문(Full Text)검색에는 InnoDB, MyISAM 스토리지 엔진에서 제공하는 일반적인 용도의 B-Tree 인덱스를 사용할 수 없다.
문서 전체에 대한 분석과 검색을 위한 인덱싱 알고리즘을 전문 검색 인덱스라고 하는데, 전문 검색 인덱스는 일반화된 기능의 명칭이지 전문 검색 알고리즘의 이름을 지칭하는 것은 아니다.
전문 검색 인덱스 알고리즘
전문 검색 인덱스는 문서의 키워드를 인덱싱하는 기법에 따라 크게 단어의 어근 분석과 n-gram 분석 알고리즘으로 구분할 수 있다.
예전에는 구분자도 하나의 알고리즘으로 평가됐지만 어근 분석과 n-gram 분석 알고리즘에 포함되었다.
어근 분석 알고리즘
MySQL 서버의 전문 검색 인덱스는 2가지 중요한 과정을 거쳐 색인 작업이 수행된다.
- 불용어 처리
- 어근 분석
불용어 처리는 검색에서 별 가치가 없는 단어를 모두 필터링해서 제거하는 과정이다.
데이터베이스 사용자가 불용어를 추가, 삭제할 수 있다.
어근 분석은 검색어로 선정된 단어의 뿌리인 원형을 찾는 작업이다.
MySQL은 오픈소스 형태소 분석 라이브러리인 MeCab을 플러그인 형태로 지원한다.
한글이나 일본어의 경우 영어와 같이 단어의 변형 자체는 거의 없기 때문에 어근 분석보다는 문장의 형태소를 분석해서 명사와 조사를 구분하는 기능이 더 중요하다.
MeCab는 일본어용이지만 일본어와 한글이 비슷해서 이를 사용한다.
MeCab는 단어 사전 필요하고 문장을 해체해서 각 단어의 품사를 식별할 수 있는 문장의 구조 인식이 필요하다.
문장의 구조 인식을 위해서는 실제 언어의 샘플을 이용해 언어를 학습하는 과정이 필요한데, 이 과정은 상당히 시간이 필요한 작업이다.
MeCab을 MySQL에 적용은 어렵지 않지만 한글에 맞게 완성도를 갖추는 작업은 많은 시간과 노력이 필요하다.
n-gram 알고리즘
mecab을 잘 활용하려면 많은 노력과 시간이 필요하다.
전문적인 검색 엔진을 고려하는 것이 아니라면 범용적으로 적용하기 쉽지 않다.
이런 단점을 보완하려고 n-gram 알고리즘이 도입됐다.
형태소 분석이 문장을 이해하는 알고리즘이라면, n-gram은 단순히 키워드를 검색해내기 위한 인덱싱 알고리즘이다.
n-gram이란 본문을 무조건 몇 글자씩 잘라서 인덱싱하는 방법이다.
형태소 분석보다는 알고리즘이 단순하고 국가별 언어에 대한 이해와 준비 작업이 필요 없는 반면, 만들어진 인덱스의 크기는 상당히 큰 편이다.
n-gram에서 n은 인덱싱할 키워드의 최소 글자 수를 의미하는데, 일반적으로는 2글자 단위르 키워드를 쪼개서 인덱싱하는 2-gram 방식이 많이 사용된다.
2-gram 예시
예시도 2-gram을 들어보겠다.
to be or not to be. that is the question
각 단어는 다음과 같이 띄어쓰기와 마침표를 기준으로 10개의 단어로 구분되고, 2글자씩 중첩해서 토큰으로 분리된다.
주의해야할 것은 각 글자가 중첩해서 2글자씩 토큰으로 구분 됐다는 것이다.
그래서 10글자 단어라면 그 단어는 2-gram 알고리즘에서는 10-1개의 토큰으로 구분된다.
이렇게 구분된 각 토큰을 인덱스에 저장하기만 하면 된다.
이때 중복된 토큰은 하나의 인덱스 엔트리로 병합되어 저장된다.
MySQL 서버는 이렇게 생성된 토큰들에 대해 불용어를 걸러내는 작업을 수행하는데,
이때 불용어와 동일하거나 불용어를 포함하는 경우 걸러서 버린다. 예를 들어 a, about, an, is 등이 있다.
아래 출력(최종 인덱스 등록) 칼럼에 표시된 것들만 전문 검색 인덱스에 등록하는 것이다.
MySQL 서버는 이렇게 구분된 토큰을 단순한 B-Tree 인덱스에 저장한다.
전문 검색 인덱스 사용하기
전문 검색 인덱스를 사용하려면 반드시 다음 두 가지 조건을 갖춰야 한다.
- 쿼리 문장이 저문 검색을 위한 문법 (MATCH ... AGAINST ...)을 사용
- 테이블이 전문 검색 대상 칼럼에 대해서 전문 인덱스 보유
n-gram에서 n은 ngram_token_size 시스템 변수로 설정이 가능하다. 기본 값은 2다.
아래와 같은 테이블이 이미 존재하고 있다.
create table item
(
id bigint auto_increment
primary key,
created_at varchar(255) null,
status varchar(255) null,
updated_at varchar(255) null,
category varchar(255) null,
image_url varchar(255) null,
information varchar(255) null,
name varchar(255) null,
price int null,
shop_id bigint null
);
이제 전문 검색 인덱스를 추가해보자.
CREATE FULLTEXT INDEX item_ngram_indx ON item (name, category) WITH PARSER ngram;
아래와 같이 쿼리를 작성할 수 있다.
select * from item where match(name, category) against('clothe' in boolean mode)
n-gram에는 다음과 같이 두 가지 규칙이 존재한다.
- 검색어의 길이가 ngram_token_size보다 작은 경우 검색이 불가능하다.
- 검색어의 길이가 ngram_token_size보다 크거나 같은 경우 검색이 가능하다.
이런 특성 때문에 한글에서는 ngram_token_size의 값으로 2가 범용적으로 적절한 선택이 될 것이다.
그러나 ngram_token_size 시스템 변수는 응용 프로그램에서 주로 검색하고자 하는 검색어의 길이에 맞게 설정해야 한다.
더 중요한 n-gram 전문 검색 인덱스의 특징은 검색어가 단어의 시작 부분이 아니고, 단어의 중간이나 마지막 부분이어도 n-gram이 검색할 수 있다는 것이다.
전문 검색 쿼리 모드
MySQL 서버의 전문 검색 쿼리는 자연어 검색 모드와 불리언 검색 모드를 지원한다.
특별히 검색 모드를 지정하지 않으면 자연어 검색 모드가 사용된다.
MySQL 서버는 자연어 검색과 함께 사용할 수 있는 검색어 확장 기능도 지원한다.
자연어 검색
- MySQL 서버의 자연어 검색은 검색어에 제시된 단어들을 많이 가지고 있는 순서대로 정렬해서 결과를 반환한다.
- 전문 검색 쿼리의 검색어에는 반드시 단일 단어만 사용되는 것은 아니다. 자연어의 문장을 그대로 사용할 수 있다.
- 문장을 그대로 검색어로 사용하는 형태를 메뉴얼에서는 Phrase Search라고 명명한다.
- 문장이 검색어로 사용되면 MySQL 서버는 검색어를 구분자로 단어를 분리하고 다시 n-gram 파서로 토큰을 생성한 후 . 각토큰에 대해 일치하는 단어의 개수를 확인해서 일치율을 계산한다.
- 즉 검색어에 사용된 모든 단어가 포함된 레코드뿐만 아니라 일부만 포함하는 결과도 가져온다.
- 검색어가 단일 단어 또는 문장인 경우 "." 과 ","등과 같은 문장 기호는 모두 무시된다.
불리언 검색
자연어 검색은 단순히 검색어에 포함된 단어들이 존재하는 결과만 가져오는 반면, 불리언 검색은 쿼리에 사용되는 검색어의 존재 여부에 대해 논리적 연산이 가능하다.
- '+' : 검색 단어는 반드시 전문 검색 인덱스 칼럼에 존재해야함
- "-": 검색 단어는 반드시 전문 검색 인덱스 칼럼에 없어야함
- ">" & "<" : 단어의 검색 기여도(우선 순위)를 변경. > 연산자는 검색 기여도를 높이고 < 연산자는 검색 기여도를 낮춘다,
- () : 표현식들을 그룹화한다. 괄호 안 그룹은 중첩이 가능하다.
- "*": 와일드카드 (ex sour -> source 등) -> 일부만 검색하는 기능을 지원하므로 필요 X
- "~": 완전히 제외는 아니지만 검색 조건 순위를 낮춘다.
- "" : 하나의 단어인 것처럼 취급
검색어 확장
검색어 확장은 사용자가 쿼리에 사용한 검색어로 검색된 결과에서 공통으로 발견되는 단어들을 모아서 다시 한번 더 검색을 수행하는 방식이다.
검색어 확장 기능은 다음과 같은 검색 요건을 만족하기 위해 도입된 기능이다.
select * from item where match(name, category) against('clothe' with query expansion)
clothe를 검색해서 jeans라는 키워드가 많아면 jeans라는 키워드로 전문 검색 쿼리를 다시 실행한다.
검색어 확장 기능은 매우 훌륭한 솔루션처럼 보이지만 원하는 결과가 많고 이를 위해 전문 검색 쿼리를 불필요하게 많이 실행할 수도 있다.
전문 검색을 QueryDsl에 적용하기
현재 프로젝트는 스프링 부트 2.7.x와 MySQL 8.x 버전을 사용하고 있다.
먼저 MySQL8Dialect 클래스를 상속해서 전문 검색을 사용할 수 있게 커스텀해야 한다.
MySQL8Dialect는 하이버네이트에서 MySQL 8.x 방언을 사용할 수 있게 해주는 클래스다.
아래와 같이 직접 전문 검색 함수를 등록한다.
public class MysqlCustomDialect extends MySQL8Dialect {
public MysqlCustomDialect(){
super();
this.registerFunction(
"match",
new SQLFunctionTemplate(StandardBasicTypes.DOUBLE, "match (?1, ?2) against (?3 in boolean mode)")
);
}
}
그러면 이제 application.yml에 직접 방언 클래스를 설정해줘야한다.
spring:
datasource:
url: jdbc:mysql://db링크
username: 유저이름
password: 비번
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
default_batch_fetch_size: 50
format_sql: true
show_sql: true
dialect: 패지지경로.MysqlCustomDialect
open-in-view: false
다음으로 사용할 엔티티 클래스를 만든다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Item extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String imageUrl;
private String information;
private String category;
private Money price;
public Item(Long id,
String name,
String imageUrl,
String information,
String category,
Integer price) {
this.id = id;
this.name = name;
this.imageUrl = imageUrl;
this.information = information;
this.category = category;
this.price = Money.from(price);
}
}
그럼 아래와 같이 QueryDsl을 사용한 조회 쿼리를 작성한다.
@Repository
public class ItemQueryRepositoryImpl implements ItemQueryRepository {
private final JPAQueryFactory query;
public ItemQueryRepositoryImpl(EntityManager em) {
this.query = new JPAQueryFactory(em);
}
public List<Item> findItemBySearchKeyword(Integer page, String searchKeyword){
return query.select(item)
.distinct()
.from(item)
.where(
fullTextBooleanTemplate(searchKeyword).gt(0)
)
.where(
item.id.gt(page)
)
.limit(11)
.fetch();
}
private NumberTemplate<Double> fullTextBooleanTemplate(String searchKeyword) {
NumberTemplate<Double> booleanTemplate = Expressions.numberTemplate(Double.class,
"function('match',{0},{1},{2})", item.name, item.category, searchKeyword);
return booleanTemplate;
}
}
그럼 마지막으로 아래와 같은 쿼리를 확인할 수 있다.
오늘은 MySQL FullText에 관련해 알아봤고 이를 QueryDsl에도 적용해봤습니다.
이상으로 포스팅을 마칩니다. 감사합니다.
참고 자료
Real MySQL 8.0
댓글