hunspell과 postgresql text search

hunspell과 한글 text search

포스트그레스큐엘 텍스트 찾기 소개

이 글은 
https://postgresql.kr/docs/current/textsearch.html
문서의 소개글이며, 
이것을 기반으로 한글 환경에서는 과연 이 기능을 어떻게 쓸 수 있는가를 검토한 글이다.

바쁜 사람들을 위한 결론, 현재로서는 
2014년(헉 10년이 되어가는군!)에 작업한 textsearch_ko 확장모듈이 그나마 최적의 대안이다.

이 글은 저 글의 확장판으로 확장 모듈을 만들고, 설정하고, 이런 낯선 작업들 없이, 기본적으로 지원하는 hunspell 형태소 분석을 이용하는 방법과 그 한계를 소개한다.

텍스트 검색 구성 & 사전 & 구문분석기 & 템플릿

PostgreSQL에서는 텍스트 검색을 위해 네개의 객체를 제공한다.
  • CREATE TEXT SEARCH CONFIGURATION: 최종 검색 환경 구성
  • CREATE TEXT SEARCH DICTIONARY: 사전
  • CREATE TEXT SEARCH PARSER: 구문분석기
  • CREATE TEXT SEARCH TEMPLATE: 사전 템플릿
관계를 설명하면, 
사전 템플릿을 만들고, 또 그것을 이용해 사전을 정의를 하고, 구문분석기를 정의하고, 최종적으로 사용할 구문분석기와 그 구문분석으로 나뉘어진 토큰들의 의미소를 뽑기 위해 사용할 사전을 연결하는 하나의 검색 환경을 만든다.
이 검색 환경 이름은 to_tsvector() - 문자열을 형태소 분석을 끝내고 의미소들만 모은 백터 자료로 바꾸는 함수와, to_tsquery() - 문자열을 형태소 분석과 기호를 통해 검색 조건을 만드는 함수에서 사용되며, 이들 함수에서 이 검색 환경 이름을 빼면, default_text_search_config DB 환경 설정 매개 변수 값을 사용한다. (초기값은 simple이다)

예제로 살펴 보는 검색 환경 구성

PostgreSQL 기본 배포판에는 각 언어별 기본 검색 환경이 미리 구성되어있다. 
그 중 하나를 예로 앞에서 설명한 것이 어떻게 구현되었는지 확인해 본다.

무조건 띄워쓰기 단위로 구분 하는 simple 환경 구성 보다는 조금 복잡한 Snowball 프로젝트에서 사용한  그 알고리즘을 기반으로 몇 언어들이 미리 설정 되어있다.

한 예로 한국어와 비슷한 터키어가 있어 살펴본다.
postgres=# \dF
                        텍스트 검색 구성 목록
   스키마   |      이름       |                 설명                  
------------+-----------------+---------------------------------------
 pg_catalog | arabic          | configuration for arabic language
 pg_catalog | armenian        | configuration for armenian language
 pg_catalog | basque          | configuration for basque language
... (중간 생략) ...
 pg_catalog | turkish         | configuration for turkish language

postgres=# \dF+ turkish
텍스트 검색 구성 "pg_catalog.turkish"
파서: "pg_catalog.default"
      토큰       |     사전     
-----------------+--------------
 asciihword      | turkish_stem
 asciiword       | turkish_stem
 email           | simple
 file            | simple
 float           | simple
 host            | simple
 hword           | turkish_stem
 hword_asciipart | turkish_stem
 hword_numpart   | simple
 hword_part      | turkish_stem
 int             | simple
 numhword        | simple
 numword         | simple
 sfloat          | simple
 uint            | simple
 url             | simple
 url_path        | simple
 version         | simple
 word            | turkish_stem
-- default 구문분석기를 이용해 쪼개진 각 토큰은 그 유형에 따라
-- 해당하는 사전을 이용해 기본형, 또는 어근 추출을 한다. 
-- 윗 예제라면, 아스키 문자들로 구성된 단어는 turkish_stem 사전을 이용해서 
-- 어근 추출을 한다.
postgres=# \dFd+ turkish_stem
                                                         텍스트 검색 사전 목록
   스키마   |     이름     |       템플릿        |                 초기화 옵션                 |                 설명                  
------------+--------------+---------------------+---------------------------------------------+---------------------------------------
 pg_catalog | turkish_stem | pg_catalog.snowball | language = 'turkish', stopwords = 'turkish' | snowball stemmer for turkish language
(1개 행)

postgres=# \dFt+ snowball
                           텍스트 검색 템플릿 목록
   스키마   |   이름   |     초기화     |      Lexize      |       설명       
------------+----------+----------------+------------------+------------------
 pg_catalog | snowball | dsnowball_init | dsnowball_lexize | snowball stemmer
(1개 행)
-- 사전과 그 사전을 구성하는 템플릿을 살펴봤다.
-- 다음은 ts_debug() 함수를 이용해서 turkish 검색 환경 구성이
-- 어떻게 내부적으로 작동했는지 살펴본 것이다.
postgres=# select * from  ts_debug('turkish', 'Sana bir masal okuyacağım.');
   alias   |    description    |   token    |  dictionaries  |  dictionary  |  lexemes   
-----------+-------------------+------------+----------------+--------------+------------
 asciiword | Word, all ASCII   | Sana       | {turkish_stem} | turkish_stem | {sa}
 blank     | Space symbols     |            | {}             |              | 
 asciiword | Word, all ASCII   | bir        | {turkish_stem} | turkish_stem | {bir}
 blank     | Space symbols     |            | {}             |              | 
 asciiword | Word, all ASCII   | masal      | {turkish_stem} | turkish_stem | {masal}
 blank     | Space symbols     |            | {}             |              | 
 word      | Word, all letters | okuyacağım | {turkish_stem} | turkish_stem | {okuyacak}
 blank     | Space symbols     | .          | {}             |              | 
(8개 행)
lexemes 칼럼에 최종 의미소 단위의 자료가 추출되는 것이다. 이것을 to_tsvector()를 이용하면, lexemes 칼럼값과 그 위치 값을 쌍으로 하는 벡터자료로 만들 수 있고, 이것을 저장하고, 인덱스를 만들어 검색 한다. 
postgres=# select to_tsvector('turkish', 'Sana bir masal okuyacağım.') @@ to_tsquery('turkish', 'masallar');
 ?column? 
----------
 t
(1개 행)

postgres=# select to_tsvector('turkish', 'Sana bir masal okuyacağım.') @@ to_tsquery('turkish', 'okudum');
 ?column? 
----------
 f
(1개 행)

postgres=# select to_tsvector('turkish', 'okudum') ;
 to_tsvector 
-------------
 'okudu':1
(1개 행)

postgres=# select to_tsvector('turkish', 'okumak') ;
 to_tsvector 
-------------
 'okumak':1
(1개 행)
이렇게 그럭저럭 의도한 찾기는 가능하다. 그럭저럭이라고 한 이유는 '읽겠다'(okuyacağım)와 '읽다'(okumak)와 '읽었다'(okudum)을 모두 다른 단어로 처리했지만, '책'(masal)과 '책들'(masallar)는 같은 단어로 처리했다. 

찾아보니, snowball 프로젝트의 내부 알고리즘이 따로 사전을 두지 않고, 각 언어별 어미 변화에 대한 규칙들만 지정해서 그냥 그 어미들을 버리는 방식을 택했기 때문이었다. 
(언어학적인 문장으로 표현하면, snowball 프로젝트는 굴절어 형식의 언어에서는 어느 정도는 유용할지 모르나, 한국어나 터키어(위에서 본것 처럼) 교착어 형식의 언어에서는 그다지 유용하지 않다.)

그래서, hunspell로 넘어갔다.

hunspell 과 텍스트 검색

hunspell 프로젝트는 그 출발점이 검색을 위한 것이라기 보다는 맞춤범 검사 기능에 초점이 맞춰진 프로젝트다.  이 프로젝트 또한 굴절어를 쓰는 동네에서 개발되었기에 한국어 환경에서 쓰기 위해서는 엄청난 삽질이 필요하다. 
그 삽질을 이미 누군가가 했다. (그 엄청난 노고에 고마운 마음을 전한다.)

한국어 hunspell 사용하기

한국어 hunspell은 한국어 맞춤법 검사 홈페이지에서 프로젝트로 운영중이다. 

PostgreSQL에서는 그저 hunspell 사전과 변화규칙 파일을 이용하면 된다.

최신 파일은 https://github.com/spellcheck-ko/hunspell-dict-ko/releases
페이지에서 받을 수 있으며,  그냥 최종 파일 (ko-aff-dic-xxx.zip) 만 받아서 바로 사용하면 된다. 

  1. wget https://github.com/spellcheck-ko/hunspell-dict-ko/releases/download/0.7.92/ko-aff-dic-0.7.92.zip
  2. unzip ko-aff-dic-0.7.92.zip
  3. cd ko-aff-dic-0.7.92
  4. cp ko.dic {PostgreSQL엔진설치디렉터리}/share/tsearch_data/hunspell_korean.dict
  5. cp ko.aff {PostgreSQL엔진설치디렉터리}/share/tsearch_data/hunspell_korean.affix
파일 다운로드, 복사 작업은 끝났다. 주의할 것은 파일 확장자를 위와 같이 바꿔야한다.

다음 공식 설명서에 나오는 대로 사전을 만든다.
postgres=# CREATE TEXT SEARCH DICTIONARY korean_hunspell (
    TEMPLATE = ispell,
    DictFile = hunspell_korean,
    AffFile = hunspell_korean);
CREATE TEXT SEARCH DICTIONARY
이때 DictFile과, AffFile에 지정한 이름이 바로 앞에서 복사한 dict, affix 확자자로 지정한 파일 이름 앞부분이다. 

다음 구문분석기는 기본 분석기(default)를 쓰고, 검색 환경 구성도 공식 설명서를 참고해서 만든다.
postgres=# create text search configuration korean_hunspell (copy=english);
CREATE TEXT SEARCH CONFIGURATION
postgres=# alter text search configuration korean_hunspell alter mapping for word with korean_hunspell, simple;
ALTER TEXT SEARCH CONFIGURATION
postgres=# \dF+ korean_hunspell
텍스트 검색 구성 "public.korean_hunspell"
파서: "pg_catalog.default"
      토큰       |          사전          
-----------------+------------------------
 asciihword      | english_stem
 asciiword       | english_stem
 email           | simple
 file            | simple
 float           | simple
 host            | simple
 hword           | english_stem
 hword_asciipart | english_stem
 hword_numpart   | simple
 hword_part      | english_stem
 int             | simple
 numhword        | simple
 numword         | simple
 sfloat          | simple
 uint            | simple
 url             | simple
 url_path        | simple
 version         | simple
 word            | korean_hunspell,simple
이렇게 영어 환경을 복사해서 한국어 환경(korean_hunspell)을 만들고, 영어(ascii 문자열로만 구성된 단어)는 snowball 영어 사전으로, 한국어는 아까 만든 korean_hunspell 사전에서 원형을 찾고 못찾으면, 그냥 그대로 사용하는 식으로 사용하는 것으로 설정했다. (alter mapping에서 korean_hunspell, simple 이렇게 지정한 순서가 중요하다)

테스트

postgres=# select * from ts_debug('korean_hunspell', '무궁화 꽃이 피었습니다');
 alias |    description    |   token    |       dictionaries       | dictionary |   lexemes    
-------+-------------------+------------+--------------------------+------------+--------------
 word  | Word, all letters | 무궁화     | {korean_hunspell,simple} | simple     | {무궁화}
 blank | Space symbols     |            | {}                       |            | 
 word  | Word, all letters | 꽃이       | {korean_hunspell,simple} | simple     | {꽃이}
 blank | Space symbols     |            | {}                       |            | 
 word  | Word, all letters | 피었습니다 | {korean_hunspell,simple} | simple     | {피었습니다}
(5개 행)
의도한 대로 의미소 단위로 처리되지 못했다.

NFD & NFC

이유는 한국어 hunspell 처리방식이 한글 글자를 자모로 모두 분리하고, 그것 기반으로 형태소 분석을 하기 때문이었다. 
즉, 의미소 분석 작업 전에, 한글 문자열을 자모단위로 분리해서 넘겨주어야한다. 

찾아보니, python unicodedata 모듈이 그일을 손쉽게 해서, 다음과 같이 간단하게 함수하나를 만들었다. python 프로시져 언어를 이용하기에, plpython3u 확장 모듈도 추가로 설치해야한다.
postgres=# create extension plpython3u;
CREATE EXTENSION
postgres=# CREATE OR REPLACE FUNCTION public.to_nfd(s text)
 RETURNS text
 LANGUAGE plpython3u
 IMMUTABLE PARALLEL SAFE
AS $function$
import unicodedata
return unicodedata.normalize('NFD', s)
$function$;
CREATE FUNCTION
다시 테스트
postgres=# select * from ts_debug('korean_hunspell', to_nfd('무궁화 꽃이 피었습니다'));
 alias |    description    |       token       |       dictionaries       |   dictionary    |   lexemes    
-------+-------------------+-------------------+--------------------------+-----------------+--------------
 word  | Word, all letters | 무궁화        | {korean_hunspell,simple} | korean_hunspell | {무궁화}
 blank | Space symbols     |                   | {}                       |                 | 
 word  | Word, all letters | 꽃이           | {korean_hunspell,simple} | korean_hunspell | {꽃}
 blank | Space symbols     |                   | {}                       |                 | 
 word  | Word, all letters | 피었습니다 | {korean_hunspell,simple} | korean_hunspell | {피다,피}
성공했다!
리눅스 그놈 터미널 환경에서는 이 자소로 분리된 문자열을 보기 좋게 보여주지만, putty와 같은 환경에서는 token과, lexemes 값이 화면에서는 이상하게 보일 것이다. 
to_nfd() 함수를 사용하지 않으려면, defaut 파서 대신에 nfd 내장된 파서를 따로 하나 만들어야한다. 시작 부분은 nfd 작업을 하고, 끝부분에서는 nfc 작업을 하는 파서여야할 것이다. 물론 textsearch_ko 모듈에서 처럼 전각 문자들을 반각 문자로 바꾸고, 한자를 한국어로 바꾸고, 등등 한국어 형태소 분석을 위한 전처리 작업이 필요하기 때문에, 한국어 hunspell 용 구문분석기(성능 좋은!)를 만들 필요는 있어보인다.

한계

사전 기반 형태소 분석에 따른 텍스트 검색 방식의 공통된 한계점이기도 하지만, 사전에 없는 비속어, 유행어, 신조어 처리 부분은 이 hunspell 방식에서도 여전히 그 한계를 보여준다. 또한 앞에서 보는바와 같이(피었습니다 -> 피, 피다) hunspell 태생 자체가 맞춤법 교정을 목적으로 설계되었기 때문에, 의도하지 않는 단어가 색인에 들어올 수 있게 된다.

나머지 

색인을 만들고, 검색 쿼리를 하고, highlighting 하고, 이런 검색 서비스의 일반적인 이야기는 공식 문서를 참고하면 될 것이다.
결론, 확장 모듈을 만들지 않고, hunspell 사전으로도 한국어 형태소 분석기 기반 검색 서비스를 만들 수 있다. 물론 여러 한계들은 있지만.