full text search는 mysql8부터 지원하는 기능이다
FULLTEXT INDEX를 걸고, MATCH ... AGAINST 구문으로 사용할 수 있다.
mysql의 innodb나 myisan엔진에서만 사용가능하다.
예시
# MATCH() in SELECT list...
SELECT MATCH (a) AGAINST ('abc') FROM t GROUP BY a WITH ROLLUP;
SELECT 1 FROM t GROUP BY a, MATCH (a) AGAINST ('abc') WITH ROLLUP;
# ...in HAVING clause...
SELECT 1 FROM t GROUP BY a WITH ROLLUP HAVING MATCH (a) AGAINST ('abc');
# ...and in ORDER BY clause
SELECT 1 FROM t GROUP BY a WITH ROLLUP ORDER BY MATCH (a) AGAINST ('abc');
MATCH ... AGAINST를 3가지 모드로 사용한다.
기본적으로는 natural모드로 동작한다.
내부적으로 문서에 각 단어들이 얼마나 많이 존재하는 지를 통해 중요도(weight)가 계산되고, 적게 존재하는 단어일수록 높은 중요도를 갖는다.
검색 시 이 중요도, 검색 필드의 길이 등에 따라 검색어가 포함된 결과를 순서대로 조회한다.
boolean mode에서는 이에 더해 더 다양한 검색 조건들을 줄 수 있다.
ex)검색어를 완전히 그대로 포함해야함 , 어떤 단어는 포함하지 않아야함...
with query expansion
는 검색 결과 안에서의 주요 단어들로 또 검색을 해 사용자가 놓쳤을 수도 있는 관련 데이터까지 검색해준다.
-단계1: 사용자가 제공한 검색어로 일반적인 FULLTEXT 검색을 수행
-단계2: 첫 번째 단계에서 얻은 가장 관련성이 높은 문서들에서 자주 등장하는 단어들을 찾아내, 이를 원래의 검색어에 추가하여 다시 한번 검색을 수행
위의 2단계로 더 넓은 관련성의 검색을 제공한다.
Full-text index는 MySQL에서 에서 FULLTEXT 타입의 인덱스이다.
그런데 원래 built-in된 기본 text parser는 공백 단위로 되어있기에
어절에 완전히 일치하는 것만 조회
해온다.
부분 문자열(part-of-word) 검색이 안된다는 것인데, 예를 들면
mysql fulltext index라는 데이터가 있고 'my', 'full'라는 검색어로 조회해도 한 어절과 완전히 일치하지는 않기에 검색되지 않는다는 것이다.
이를 글자 단위로 검색가능하게 하기 위해 n-gram parser를 지원한다.
다만 더 작은 단위로 text의 인덱스들을 많이 생성하기에 더 많은 메모리를 사용한다는 것을 인지해야한다.
아주 많은 text에 대해 n-gram parser의 token사이즈를 작게 잡으면 db에서도 인덱스 사용 시 디스크에 접근할 가능성이 높아져 성능이 저하될 수 있다.
https://dev.mysql.com/doc/refman/8.0/en/fulltext-search-ngram.html
https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html
alter table cafe add FULLTEXT INDEX name_idx (name) with parser ngram
이후
show global variables like 'ngram_token_size';
로 토큰 사이즈 확인. 기본은 2로 설정되어 있다.
INSERT INTO cafe (name, address, description, map_url, phone)
SELECT
CONCAT(
'Cafe ',
CHAR(65 + FLOOR(RAND() * 26)), -- Random uppercase letter (A-Z)
CHAR(97 + FLOOR(RAND() * 26)), -- Random lowercase letter (a-z)
CHAR(97 + FLOOR(RAND() * 26)), -- Random lowercase letter (a-z)
' Cafe'
) AS name,
CONCAT('Address 주소', FLOOR(RAND() * 10000)) AS address,
'Description' AS description,
CONCAT('Map URL ', FLOOR(RAND() * 10000)) AS map_url,
CONCAT('Phone ', FLOOR(RAND() * 10000)) AS phone
FROM (
SELECT n FROM (
SELECT a.n + b.n*10 + c.n*100 + d.n*1000 as n
FROM
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d
) numbers
) rand_data;
INSERT INTO cafe (name, address, description, map_url, phone)
SELECT
CONCAT(
'Cafe ',
CHAR(65 + FLOOR(RAND() * 26)), -- Random uppercase letter (A-Z)
CHAR(97 + FLOOR(RAND() * 26)), -- Random lowercase letter (a-z)
CHAR(97 + FLOOR(RAND() * 26)), -- Random lowercase letter (a-z),
' Cafe'
) AS name,
CONCAT('Address 주소주소주소', CHAR(97 + FLOOR(RAND() * 26)), CHAR(97 + FLOOR(RAND() * 26)), FLOOR(RAND() * 10000)) AS address,
'Description' AS description,
CONCAT('Map URL ', FLOOR(RAND() * 10000)) AS map_url,
CONCAT('Phone ', FLOOR(RAND() * 10000)) AS phone
FROM (
SELECT n FROM (
SELECT a.n + b.n*10 + c.n*100 + d.n*1000 as n
FROM
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d
) numbers
) rand_data;
-- Set the session to allow auto-increment values during inserts
SET @@auto_increment_increment=1;
SET @@auto_increment_offset=1;
-- Loop to generate and insert 10,000 random 8-character strings into the menu table
INSERT INTO `yozm-cafe`.menu (cafe_id, priority, name, image_url, description, price, is_recommended)
SELECT
(SELECT id FROM `yozm-cafe`.cafe ORDER BY RAND() LIMIT 1), -- Select a random cafe_id
FLOOR(RAND() * 100),
CONCAT(
CHAR(65 + FLOOR(RAND() * 26)),
CHAR(97 + FLOOR(RAND() * 26)),
CHAR(65 + FLOOR(RAND() * 26)),
CHAR(97 + FLOOR(RAND() * 26)),
CHAR(65 + FLOOR(RAND() * 26)),
CHAR(97 + FLOOR(RAND() * 26)),
CHAR(65 + FLOOR(RAND() * 26)),
CHAR(97 + FLOOR(RAND() * 26))
),
CONCAT('Image URL ', FLOOR(RAND() * 10000)),
'Description',
CONCAT('$', FLOOR(RAND() * 100)),
FLOOR(RAND() * 2)
FROM
(SELECT n FROM
(SELECT a.n + b.n*10 + c.n*100 + d.n*1000 as n
FROM
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c,
(SELECT 0 as n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d
) numbers
) rand_data
LIMIT 10000; -- Limit to 10,000 rows
랜덤한 데이터를 넣어 테스트하지 않아 실패... 실험준비에서 처럼 랜덤한 데이터를 넣어주어야 한다.
address 검색
name검색
이 경우 전체조회를 하는건데 그러면 인덱스를 타는 의미가 없다
랜덤한 더미데이터를 만들어야 한다.
랜덤한 데이터 - 조건2개일 경우
5만개의 카페와 2만개의 메뉴 데이터에서 검색한다
한 조건에 대해서만이면 인덱스를 건게 조금 더 효율적이다. 그런데 여러 조건에 걸쳐 검색되면서 범위 터플이 적어진 상태에서는 인덱스를 건 게 오히려 느릴 수 있다
SELECT
c.id,
c.address,
c.description,
c.map_url,
c.phone,
c.like_count,
c.name
FROM
cafe c
JOIN
menu m ON m.cafe_id = c.id
WHERE
LOWER(c.name) LIKE '%e C%' ESCAPE '!'
AND LOWER(c.address) LIKE '%hw%' ESCAPE '!';
0.07초
0.07초
SELECT
c.id,
c.address,
c.description,
c.map_url,
c.phone,
c.like_count,
c.name
FROM
cafe c
JOIN
menu m ON m.cafe_id = c.id
WHERE
MATCH (c.name) AGAINST ('i C' IN BOOLEAN MODE)
AND MATCH (c.address) AGAINST ('cq' IN BOOLEAN MODE);
랜덤한 데이터 - 조건 1개일 경우
fulltext search가 조금 더 빠르다.
쿼리
결과
1개의 쿼리에 대해서는 fulltext search가 좀 더 빠르다.
다만 검색 조건이 중첩되어 여러 번의 조회를 할 경우에,
한 번 걸러진 상태에서는 해당 데이터들을 full-scan하는 like검색이 더 빠른 상황도 있다.
우리 서비스에서는 조건을 중첩해서 검색할 상황은 많지 않을거라 생각해 fulltext search를 도입하기로 한다.