<-home

SQLite FTS의 토크나이저와 인덱싱에 대해서

목차

전문 검색(Full-Text Search)이란?

우리가 구글에서 “오늘 서울 날씨”라고 검색하면, 구글은 어떻게 요청 데이터를 처리할까요? 아마도 구글은 ‘오늘’, ‘서울’, ‘날씨’라는 단어가 포함된 수많은 웹페이지 중에서 가장 관련성 높은 문서를 찾아서 보여줄겁니다.

이처럼, 전문 검색(Full-Text Search)은 단순히 문자가 완전히 일치하는지(예: LIKE '%검색어%')를 확인하는 것을 넘어, 문장이나 문단 전체에서 특정 단어나 구문이 포함된 문서를 빠르고 효율적으로 찾아주는 기술입니다. 일반적인 데이터베이스 검색(예: WHERE title = '검색어')이 책의 ‘목차’에서 제목을 찾는 것이라면, 전문 검색은 일반적으로 책의 맨 뒤에서 볼 수 있는 ‘찾아보기(인덱스)’를 이용해 본문 내용 안에서 단어를 찾는 것과 같습니다.

SQLite 데이터베이스는 이 ‘전문 검색’ 기능을 사용할 수 있도록 해주는 강력한 확장 기능(extension)인 FTS를 제공합니다. ‘FTS’는 Full-Text Search의 약자이고, 뒤에 숫자로 버전을 명시합니다. 예를 들어 FTS5라는 것은 5번째 버전을 의미합니다. 앞으로 설명할 FTS는 FTS5를 의미합니다.

FTS는 어떻게 동작하나요?

FTS는 일반적인 검색보다 훨씬 빠르고 효율적으로 동작하기 위해, 아래와 같은 과정을 거칩니다.

1. 가상 테이블(Virtual Table) 생성

전문 검색을 할 텍스트 데이터(예: 상품 설명)를 일반 테이블이 아닌 FTS 전용의 ‘가상 테이블(VIRTUAL)’에 저장합니다.

-- 'product_fts' 라는 이름의 FTS5 가상 테이블을 생성
CREATE VIRTUAL TABLE product_fts USING fts5(
    Name,         -- 상품명
    Description   -- 상품설명
    -- , tokenize = 'porter' -- (옵션) 추가적인 토크나이저 설정
);

2. 토큰화(Tokenization)

FTS5 테이블에 텍스트를 저장하면, FTS5는 이 텍스트를 의미 있는 최소 단위의 단어, 즉 ‘토큰(token)’으로 분해합니다. 예를 들어, “최고의 서울 야경 투어”라는 문장은 ‘최고’, ‘서울’, ‘야경’, ‘투어’ 같은 토큰으로 쪼개지는데, 이 과정에서 ‘의’ 같은 조사는 보통 무시됩니다.

3. 인덱싱(Indexing)

앞에서 쪼개진 토큰들을 바탕으로 ‘역인덱스(inverted index)’라는 특별한 목록을 만듭니다. 이 목록에는 어떤 단어(토큰)가 어떤 문서(row)에 나타나는지에 대한 정보가 기록됩니다.

예를 들어, ‘서울’이 포함된 문서 목록(1, 5, 12)과 ‘야경’이 포함된 문서 목록(1, 7)을 찾아서 아래와 같이 매핑합니다.

  • ‘서울’ -> 1번, 5번, 12번 문서에 있음
  • ‘야경’ -> 1번, 7번 문서에 있음

4. 검색(Search)

사용자가 MATCH 연산자를 사용해 “서울 야경”이라고 검색하면, FTS는 앞에서 생성한 역인덱스를 이용해서 ‘서울’과 ‘야경’이 포함된 문서를 빠르게 찾아냅니다. 그리고 두 목록에 공통으로 존재하는 문서를 최종 결과로 반환합니다.

좀더 자세히 알아보기

아래와 같이 다양한 옵션을 사용해서 가상 테이블을 설정해봅시다.

CREATE VIRTUAL TABLE table_name USING FTS5(
    id UNINDEXED,
    sub_id,
    comment,
    field UNINDEXED,
    text,
    prefix = '1 2 3',
    tokenize = 'unicode61 remove_diacritics 2'
)

먼저 결론을 말씀드리면, 이 명령어를 통해 대소문자를 무시하고, 발음 기호를 제거하며, 공백/문장부호로 단어를 나누는 규칙으로 sub_id, comment, text 컬럼의 내용을 토큰으로 만들어 저장하는 FTS 테이블을 생성합니다.

검색 대상 컬럼 선정

일부 필드에는 UNINDEXED라는 옵션이 붙어있는 것을 볼수 있는데요, 이 옵션이 붙어있는 컬럼은 테이블에 저장되기는 하지만 전문 검색의 대상이 되지는 않습니다. 즉, MATCH 연산자를 사용해서 이 컬럼들을 검색할 수 없습니다.

  • 검색 대상: sub_id, comment, text
  • 검색 비대상: id, field

검색 대상 컬럼에 있는 텍스트는 토큰으로 분해되어 검색용 인덱스로 만들어집니다.

토큰화(Tokenization)

토큰화(Tokenization)는 바로 tokenize 옵션에 의해 결정됩니다. 코드를 보면 unicode61 remove_diacritics 2라는 옵션이 붙어있는 것을 볼 수 있습니다. 이 옵션은 2가지 옵션을 포함하고 있습니다.

unicode61

사용할 토크나이저(Tokenizer, 분해기)의 이름입니다. 이름 그대로 유니코드 6.1 표준에 정의된 규칙에 따라 텍스트를 단어로 분해합니다. 이 분해기는 크게 2가지 특징을 가지고 있습니다.

  1. Case-insensitive: 대소문자를 구분하지 않습니다. Apple, apple, APPLE은 모두 같은 토큰 ‘apple’로 처리됩니다.
  2. Separator: 공백(space), 마침표(.), 쉼표(,) 등 문장 부호를 기준으로 단어를 나눕니다.

remove_diacritics 2

토크나이저에 전달하는 추가 옵션입니다. 여기서 diacritic은 발음 구별 기호(예: é, ü, ñ)를 의미하고, 결국 앞에 remove가 붙었다는 것은 발음 기호를 제거하라는 옵션이 됩니다.

예를 들어,

  • résumé -> resume로,
  • café -> cafe

로 변환되어 토큰으로 만들어집니다.

그럼 뒤에 숫자는 무엇을 의미할까요? 이러한 기능을 처리하는 내부적인 알고리즘의 버전을 의미합니다. 즉 2버전을 기반으로 발음 기호를 제거하라는 옵션입니다.

prefix

단어의 앞부분만으로도 빠르게 검색할 수 있도록 접두어 인덱스(prefix index)를 만듭니다. ‘1 2 3’의 의미는 한 글자, 두 글자, 세 글자짜리 접두어에 대한 인덱스를 미리 만들어두겠다는 뜻입니다.

예를 들어, apple이라는 단어가 있다면 a, ap, app에 대한 인덱스가 생성됩니다. 이를 통해 사용자는 ‘app’ 까지만 입력해도 apple을 매우 빠르게 찾아낼 수 있습니다.

자동완성 기능이라고 생각하시면 이해하기 좋습니다.

예시 1. 영어 텍스트 토큰 분해 과정

원본 텍스트 저장

“The best Café in Seoul is near City Hall.” 라는 원본 텍스트가 있다고 가정합니다. 이 텍스트는 text 컬럼에 저장됩니다.

토큰화 과정

unicode61 토크나이저는 대소문자를 통합하고 문장 부호를 기준으로 단어를 나눕니다.

결과로 [the, best, café, in, seoul, is, near, city, hall]이 생성됩니다.

발음 기호 제거

remove_diacritics 옵션은 발음 기호를 제거합니다. café -> cafe

결과로 [the, best, cafe, in, seoul, is, near, city, hall]이 생성됩니다.

접두어 인덱스 생성

단어의 앞부분만으로도 빠르게 검색할 수 있도록 접두어 인덱스(prefix index)를 만듭니다.

위에서 prefix 뒤에 붙은 ‘1 2 3’의 의미는 한 글자, 두 글자, 세 글자짜리 접두어에 대한 인덱스를 미리 만들어두겠다는 뜻입니다.

예를 들어, apple이라는 단어가 있다면 a, ap, app에 대한 인덱스가 생성됩니다. 덕분에 사용자가 ‘app’까지만 입력해도 apple을 매우 빠르게 찾아낼 수 있습니다. 자동완성과 동일한 기능이라고 볼 수 있습니다.

예시 2. 한글 텍스트 토큰 분해 과정

원본 텍스트 저장

“최고의 서울 야경 투어!”라는 원본 텍스트가 있습니다. 이 텍스트 또한 text 컬럼에 저장됩니다.

토큰화 과정

여기서 중요한 점이 있습니다. unicode61 토크나이저는 한글의 형태소(명사, 조사 등)를 분석하는 기능이 없기 때문에, 공백과 문장 부호를 기준으로만 단어를 나눕니다.

그러므로 최종 생성 토큰은 [최고의, 서울, 야경, 투어]가 됩니다.

이때 사용자가 ‘최고’라고 검색하면 어떻게 될까요? 토큰으로 저장된 결과는 ‘최고의’뿐이므로, ‘최고’라는 토큰을 찾을 수 없게 됩니다. 이것이 unicode61 토크나이저를 한글에 사용할 때의 한계점입니다. 그래서 일반적으로 더 정확한 한글 검색을 위해서는 별도의 한글 형태소 분석기를 FTS에 연동합니다.

그렇다면 궁금한 점이 생깁니다. 위에서 prefix해서 인덱스를 추가로 생성할 수 있다고 했는데, 그러면 ‘최고의’ 토큰은 [‘최’, ‘최고’, ‘최고의’] 인덱스를 가지게 되니까 ‘최고’를 검색할 때도 ‘최고의’를 찾을 수 있게 되는 거 아닌가?

결론부터 말씀드리면, 아쉽게도 그렇게 동작하지 않습니다.

이유를 명확히 이해하려면 토큰화(Tokenization)접두어 인덱싱(Prefix Indexing)의 역할을 정확하게 이해해야 합니다.

FTS에서 가장 먼저 일어나는 일은 입력된 텍스트에서 ‘단어(토큰)’가 무엇인지 정의하는 것입니다.

위에서 unicode61은 공백을 기준으로 단어를 나누므로, FTS는 이 텍스트에 최고의, 서울, 야경 이라는 3개의 단어(토큰)만 존재한다고 인식하고 저장합니다. 이 시점에서 FTS의 ‘단어 사전’에는 최고의라는 단어는 있지만, 최고라는 단어는 존재하지 않습니다. 토크나이저가 그렇게 정의했기 때문입니다.

이 다음에 진행되는 prefix (접두어 인덱싱)은 “결정된 단어를 빨리 찾기 위한 ‘색인’ 추가”의 역할을 합니다. 중요한 점은 ‘생성된 색인이 토큰이 되지는 않는다’는 것입니다. 특정 토큰으로 이동할 수 있는 지름길을 만드는 것이지, 이미 만들어진 토큰을 글자수 단위로 더욱 세밀하게 쪼개서 토큰으로 생성하라는 의미가 아닙니다.

  • 최고의라는 토큰에 대해: [최 -> 최고의], [최고 -> 최고의]로 가는 지름길을 만듭니다.
  • 서울이라는 토큰에 대해: [서 -> 서울], [서울 -> 서울]로 가는 지름길을 만듭니다.
  • 야경이라는 토큰에 대해: [야 -> 야경], [야경 -> 야경]로 가는 지름길을 만듭니다.

그러므로 최나 최고가 독립적인 단어로 사전에 추가되는 것이 아니라, 오직 ‘최고의’라는 원본 단어를 더 빨리 찾기 위한 ‘색인’ 또는 ‘포인터’의 역할만 하게 됩니다.

검색 시 어떻게 동작하는가?

한글 텍스트를 기준으로 사용자가 검색을 시도하였을 때 어떻게 동작하는지 알아보겠습니다.

CASE 1: … MATCH '최고' 라고 검색한 경우

FTS는 단어 사전에서 최고라는 토큰이 완벽하게 일치하는 항목이 있는지 먼저 찾습니다. 하지만 단어 사전에는 ‘최고의’만 있을 뿐 ‘최고’라는 단어는 없습니다.

이때 접두어 인덱스는 사용되지 않는데요, 오직 * 와 함께 사용될 때만 접두어 인덱스는 활성화됩니다.

그러므로 결과적으로 문서를 찾지 못합니다.

CASE 2: … MATCH '최고*' 라고 검색한 경우

FTS는 검색어 끝에 *가 붙은 것을 보고, 접두어 인덱스를 활용한 검색을 수행합니다. 접두어 인덱스를 확인한 결과, ‘최고’라는 단어가 ‘최고의’ 토큰을 가리키는 지름길로 등록되어있음을 확인합니다.

이 지름길이 가리키는 ‘최고의’ 토큰이 포함된 문서를 찾아 성공적으로 반환합니다.

FTS의 장점

일반적으로 사용되는 WHERE content LIKE '%검색어%' 방식과 FTS 방식을 간단하게 비교해보면, FTS 방식이 훨씬 빠르고 효율적이라는 것을 알 수 있습니다.

구분 FTS (MATCH) 일반 검색 (LIKE)
속도 매우 빠름. 미리 만들어 둔 인덱스를 사용해 검색하기 때문에 데이터가 수십만 건이 되어도 성능 저하가 거의 없습니다. 매우 느림. 테이블의 모든 데이터를 하나씩 처음부터 끝까지 다 읽어서 비교(Table Full Scan)하기 때문에 데이터가 많아지면 속도가 급격히 느려집니다.
정확도/기능 - 단어(토큰) 단위로 검색하여 더 정확합니다.
- AND, OR, NOT 등 논리 연산이 가능합니다.
- “서울 야경”처럼 여러 단어가 가까이 있는 문서를 찾는 NEAR 검색도 가능합니다.
- 검색 결과의 관련도 순으로 정렬(rank)할 수 있습니다.
- 단순히 문자열 포함 여부만 체크합니다.
- 복잡한 조건의 검색이 어렵습니다.