최근 프로젝트 진행중에 검색 기능을 개선해달라는 요청을 받았다. 이유인즉슨, 예를 들어 "방화살인사건" 이라는 단어를 검색하면 동일한 단어만 검색이 되어 불편하다는 것이다. 방화, 살인, 사건 각각의 단어들이 있다면 검색이 되도록 하고 싶다는 요청이었다.
이를 해결하기 위해 검색 솔루션을 도입하는 방법도 있겠으나, 프로젝트의 예산에 맞지 않는 해결 방법이었다. 그리고 요구하신 내용의 개선을 위해서 솔루션을 도입하는 것은 닭 잡는데 소 잡는 칼을 사용하는 수준의 해결 방법이었기에 맞지 않는다고 생각했다.
비용적으로 부담되지 않으며, 프로젝트 일정에도 무리가 없는 범위에서 다시 해결방법을 찾아야했다. 그래서 생각한 방법은 다음과 같은 방법이다.
전제로 해야 하는 조건은 현재 데이터베이스는 mariadb 를 사용하고 있다는 것이다. (아래에 설명되는 방법은 mysql 에도 동일한 방법으로 사용할 수 있으며, 다른 RDBMS 에서도 비슷한 기능이 있을 것으로 생각된다.)
- 게시글에 대한 형태소 분리 및 단어 분리를 한다.
- 분리된 내용을 인덱싱 테이블을 생성하여 담는다.
- 검색어가 주어지면 검색어에 대한 형태소 분리 및 단어 분리를 한다.
- 분리된 단어를 이용하여 인덱싱 테이블 Full Text Scan 을 한다.
인덱싱 테이블을 생성하자.
CREATE TABLE `search_indices` (
`id` int(11) NOT NULL COMMENT '인덱스',
`model` varchar(30) NOT NULL COMMENT 'Model',
`type` varchar(30) NOT NULL COMMENT 'Type',
`content` text DEFAULT NULL COMMENT '인덱스 내용',
PRIMARY KEY (`id`,`model`,`type`),
KEY `search_idx` (`model`,`type`,`id`),
FULLTEXT KEY `fidx` (`content`)
)
형태소 분리 및 단어 분리
형태소 분리와 단어 분리를 위한 오픈소스를 검색후 바른 형태소 분석기를 사용하기로 했다. 일단 docker 로 되어있어, 현재 운영중인 시스템의 환경과 관계없이 작동시킬 수 있었다. API 도 이미 구현되어있어서 현재 시스템에 이식하여 원하는 기능을 사용하는데 드는 수고가 적었다. 추후 업데이트에도 용이한 장점이 있었다.
형태소 분리 결과물도 만족하는 수준이었다. 속도도 나쁘지 않은 수준이었다. 게시글이 등록될 때 및 검색을 수행할 때 검색어에 대해 한번 수행하는 것이라 조금 느리다 해도 속도는 큰 문제가 되지 않았다.
바른 형태소 분석기 : https://bareun.ai/
바른 형태소 분석기 (도커 사용법) : https://docs.bareun.ai/install/docker/
형태소 분석 API
POST 로 /bareun/api/v1/analyze 에 형태소 분석 요청을 합니다.
아래는 샘플소스입니다.
public static function getAnalyze($content)
{
$url = 'http://localhost:5757/bareun/api/v1/analyze';
$postData = [
'document' => [
'content' => $content,
'language' => 'ko-KR'
],
'encoding_type' => 'UTF8',
'auto_split_sentence' => false,
'auto_spacing' => true,
'auto_jointing' => true,
];
$postData = json_encode($postData);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_PORT, 5757);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_NOSIGNAL, 0);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 5000);
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 5000);
// curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$headers = array();
$headers[] = "api-key: ".BAREUN_APIKEY;
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status_code == 200) {
$retVal = json_decode($response);
} else {
return null;
}
return $retVal;
}
public static function getIndexedText($content) {
// 형태소 분리를 이용하는 경우
$indexedText = '';
$content = trim($content);
if($content == '') {
return $indexedText;
}
$result = self::getAnalyze($content);
if(!isset($result->sentences[0])) {
return $indexedText;
}
foreach($result->sentences as $sentence) {
foreach($sentence->tokens as $token) {
foreach($token->morphemes as $morph) {
if(in_array($morph->tag[0], [
'N',
'V',
'S',
'M',
])) {
$indexedText.= $morph->text->content.' ';
}
}
}
}
if(trim($indexedText) != '') {
return trim($indexedText);
} else {
return trim($content);
}
}
위 요청을 통해 받게된 값중에서 품사 N,V,S,M 에 해당하는 값만을 인덱싱 테이블에 입력합니다.
품사태그는 다음 링크를 참조하세요.
https://docs.bareun.ai/howtouse/tag-info/
N = 명사, V = 용언 (동사, 형용사, 보조용언), S = 기호 (일반기호, 외국어, 한자, 숫자 등), M = 수식언 (관형사, 부사) 에 대해서만 인덱싱을 남겨둡니다.
> 위 부분은 개선을 계획 중에 있습니다. 인덱싱 된 데이터를 보니 필요 없는 데이터가 꽤나 보입니다.
위처럼 데이터를 저장하면 아래와 같은 인덱싱 데이터가 생깁니다.
원본 -> 기아 타이거즈가 에이스 제임스 네일의 예기치 않은 부상으로 정규시즌 막판 최대 위기에 맞닥뜨렸습니다. 시즌 내내 활활 타올랐던 막강한 타선의 힘과 함께 이범호 감독의 용병술이 더욱 중요해졌습니다.
형태소분리 -> 기아 타이거즈 에이스 제임스 네일 예기하 않 부상 정규 시즌 막판 최대 위기 맞닥뜨리 . 시즌 내내 활활 타오르 막강하 타선 힘 함께 이범호 감독 용병술 더욱 중요하 지 .
위와 같이 인덱싱 되었을때 갖게되는 검색의 이점 중 하나는 " 중요할까? 중요하니까? 중요하다. 중요합니다. " 등의 방법으로 검색을 하더라도 형태소 분석에 의해 "중요하" 라는 문자를 검색하게 되기에 모든 데이터를 검색되게할 수 있는 장점이 있습니다.
고객의 요청이었던 "방화살인사건" 이라는 단어 또한 "방화 + 살인 + 사건" 으로 검색이 가능해집니다.
데이터 검색 쿼리
SELECT `post_articles`.*
FROM post_articles
INNER JOIN (
SELECT
id, MATCH (search_indices.content) AGAINST ('+타이거즈* ' IN BOOLEAN MODE) AS score
FROM search_indices
WHERE MATCH (search_indices.content) AGAINST ('+타이거즈* ' IN BOOLEAN MODE)
AND `search_indices`.`model` = 'post'
AND `search_indices`.`type` = 'article'
ORDER BY id DESC
) si ON `si`.`id` = `post_articles`.`id`
WHERE `post_articles`.`del_dt` IS NULL
AND `post_articles`.`status` = 'Y'
AND `pub_dt` BETWEEN '2024-05-26 00:00:00' AND '2024-08-26 23:59:59'
ORDER BY `post_articles`.`pub_dt` DESC
위 쿼리는 현재 사용중인 쿼리를 간단히 요약해봤습니다. 현재 많은 데이터는 아니나 백만건이 넘는 데이터를 검색하는데 약 1msec 의 속도로 데이터를 검색해낼 수 있습니다.