Menu

SQLite FTS5 전문 검색: 가상 테이블과 MATCH 사용법

SQLite에 FTS5로 전문 검색을 붙이는 방법을 정리했습니다. 가상 테이블 생성부터 MATCH 연산자, BM25 랭킹, 원본 테이블과 인덱스 동기화까지 한 번에 살펴봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 — 편집하고 실행하면 결과를 바로 볼 수 있습니다.

LIKE로는 한계가 있다

SQLite에서 텍스트를 검색해 본 적이 있다면 십중팔구 LIKE '%word%'부터 떠올렸을 겁니다. 작은 테이블에서는 잘 동작하지만, 데이터가 커지면 금세 무너지죠. 도움이 될 만한 인덱스가 없기 때문에 SQLite는 모든 행을 훑으면서 소문자로 바꾸고 부분 문자열을 일일이 확인해야 합니다. 게다가 단어 경계, 랭킹, 다중 키워드 검색, 접두사 매칭 같은 건 전부 직접 구현해야 합니다.

이때 정답이 바로 SQLite에 내장된 FTS5(sqlite full text search) 입니다. FTS5는 텍스트 컬럼에 역색인(inverted index)을 자동으로 유지해 주는 가상 테이블(fts5 가상 테이블)로, 자체 쿼리 문법을 이해하고 BM25로 결과 순위까지 매겨 줍니다. 게다가 SQLite에 기본 포함되어 있어서 별도로 확장 모듈을 설치할 필요도 없습니다.

fts5 가상 테이블 만들기

FTS5 테이블은 CREATE VIRTUAL TABLE ... USING fts5(...) 구문으로 만들고, 색인할 텍스트 컬럼을 나열하면 됩니다:

여기서 눈여겨볼 점이 세 가지 있습니다. 먼저 컬럼에 타입이 없습니다 — FTS5는 모든 값을 텍스트로 다루기 때문이죠. 그리고 MATCH 연산자는 컬럼이 아니라 테이블 이름을 대상으로 씁니다(posts MATCH ... 형태). 마지막으로 쿼리는 대소문자를 구분하지 않고 토큰 단위로 매칭되기 때문에, 'sqlite'로 검색해도 어느 행에 들어 있든 SQLite를 찾아냅니다.

MATCH 쿼리 문법 살펴보기

MATCH에는 단어 하나만 넣을 수 있는 게 아닙니다. 쿼리 문자열은 나름의 작은 문법 체계를 갖추고 있습니다:

각각의 의미를 정리하면 이렇습니다:

  • 'fts5 AND prefix' — 두 토큰이 모두 등장해야 함 (순서 무관, 위치 무관).
  • '"keep fts"' — 정확히 이 순서대로 등장하는 구문 검색.
  • 'trig*' — 접두사 검색. trigger, triggers, trigonometry 등에 매칭됩니다.
  • 'index NOT trigger'index는 포함하되 trigger는 포함하지 않는 결과.

특정 컬럼만 노리고 싶다면 column:term 형태로 쓸 수 있습니다. 예를 들어 'title:sqlite' 처럼요. 전체 문법에는 그룹핑을 위한 괄호와 OR 연산자도 있어서, 일반적인 검색 엔진에서 기대하는 표현은 거의 다 지원한다고 보면 됩니다.

BM25로 검색 결과 랭킹 매기기

FTS5는 기본적으로 모든 행에 숨겨진 rank 컬럼을 붙여줍니다. 이 값이 바로 BM25 관련도 점수인데, 숫자가 작을수록 더 잘 맞는 결과입니다. 따라서 이 컬럼 기준으로 정렬하면 가장 관련도 높은 결과부터 받아볼 수 있습니다:

특정 컬럼에 더 큰 가중치를 주고 싶다면, 선언 순서에 맞춰 컬럼별 가중치를 bm25() 함수에 넘겨주면 됩니다.

첫 번째 글이 위로 올라온 이유는 sqlitebody(가중치 1×)뿐 아니라 title(가중치 10×)에도 등장했기 때문입니다. 가중치는 실제 앱에서 어떤 필드를 더 중요하게 볼지에 맞춰 정하면 됩니다.

검색 인덱스 동기화 유지하기

가장 단순한 FTS5 테이블은 본문 텍스트를 자체적으로 복사해서 보관합니다. 로그처럼 INSERT만 일어나는 데이터라면 이 방식이 충분하지만, 보통은 이미 원본 테이블이 따로 있고 FTS 인덱스가 그걸 따라가도록 만들고 싶을 겁니다. 이럴 때 깔끔한 패턴이 바로 외부 콘텐츠(external content) FTS 테이블에 트리거 세 개를 붙이는 방식이죠.

content='articles' 옵션은 FTS5에게 본문 텍스트 자체는 저장하지 말라고 알려주는 설정이에요. 필요할 때마다 원본인 articles 테이블에서 꺼내 쓰는 방식이죠. 트리거는 articles에 일어난 변경을 FTS 인덱스에 그대로 반영하는 역할을 합니다. 결국 articles가 원본 데이터(single source of truth)가 되고, articles_fts는 그 옆에 붙은 검색 전용 구조가 되는 셈이에요.

조금 어색해 보이는 INSERT INTO articles_fts(articles_fts, ...) VALUES ('delete', ...) 구문은 FTS5에서 인덱스에 "이 행을 지워라"라고 명령할 때 쓰는 특수 문법입니다.

검색어 스니펫과 하이라이팅

검색 결과를 보여줄 때는 보통 매칭된 키워드가 강조된 미리보기 형태가 필요하죠. FTS5는 이를 위한 두 가지 함수를 제공합니다:

  • highlight(table, column_index, open, close)은 매칭된 토큰을 지정한 태그로 감싼 컬럼 전체 텍스트를 돌려줍니다.
  • snippet(table, column_index, open, close, ellipsis, token_count)은 매칭 위치를 중심으로 짧은 발췌문을 만들어 줍니다.

컬럼 인덱스는 선언 순서대로 0부터 시작합니다. 검색 UI에서 흔히 보는 "매칭된 단어를 노란색으로 강조" 기능은 결국 이 두 함수로 만들어집니다.

알아두면 좋은 함정들

자주 발이 걸리는 지점 몇 가지를 정리합니다.

  • MATCH는 FTS 테이블에서만 동작합니다. 일반 테이블의 컬럼에 MATCH를 쓸 수는 없습니다. 기존 테이블을 검색해야 한다면 위에서 살펴본 외부 콘텐츠 테이블 패턴을 사용하세요.
  • rank로 정렬하는 걸 잊지 마세요. 빼먹으면 FTS5는 저장 순서대로 결과를 돌려주는데, 이건 관련도와 아무 상관이 없습니다.
  • 토크나이저 선택이 중요합니다. 기본 토크나이저인 unicode61은 유니코드 단어 경계로 분할하고 소문자로 정규화합니다. runrunning과 매칭되도록 어간 추출을 원한다면 porter 토크나이저를 쓰면 됩니다: USING fts5(body, tokenize='porter').
  • FTS5는 오타를 허용하는 엔진이 아닙니다. 접두사 검색은 지원하지만 퍼지 매칭은 하지 않습니다. "혹시 이걸 찾으셨나요?" 같은 동작이 필요하다면 FTS5 위에 별도 레이어를 얹어야 합니다.
  • 콘텐츠 없는 테이블(content='')은 가볍지만 원문을 잃습니다. 검색은 가능하지만 원본 텍스트는 가져올 수 없고 rowid만 얻을 수 있습니다. 원문을 다른 곳에 저장해 두는 경우에 유용합니다.

다음 주제: 윈도우 함수

지금까지는 텍스트 검색 이야기였습니다. 다음 페이지에서는 결이 다른 고급 쿼리, 즉 윈도우 함수를 다룹니다. 행을 집계로 뭉개지 않고도 누적 합계, 순위, 그룹별 분석을 계산할 수 있게 해주는 기능입니다.

자주 묻는 질문

SQLite의 FTS5가 뭔가요?

FTS5는 SQLite에 내장된 전문 검색(full-text search) 확장입니다. CREATE VIRTUAL TABLE ... USING fts5(...) 구문으로 전용 가상 테이블을 만든 다음 MATCH 연산자로 검색하면 됩니다. 데이터를 넣을 때 자동으로 토큰화해서 역인덱스(inverted index)에 저장하고, 기본적으로 BM25 점수로 결과를 정렬해 줍니다.

MATCH랑 LIKE는 뭐가 다른가요?

LIKE는 단어 경계를 무시하고 문자열을 그냥 훑는 방식이라 데이터가 많아지면 느려집니다. 반면 MATCH는 FTS5의 역인덱스를 사용하기 때문에 큰 테이블에서도 빠르고, 토큰 단위 검색은 물론 접두사 검색(term*), 불리언 연산자(AND, OR, NOT), 구문 검색("정확한 문구")까지 지원합니다. 단, MATCH는 FTS 가상 테이블에서만 쓸 수 있다는 점을 기억하세요.

원본 테이블과 FTS5 인덱스를 어떻게 동기화하나요?

두 가지 방법이 있습니다. 첫째, content='posts' 같은 외부 콘텐츠(external-content) 또는 콘텐츠리스(contentless) FTS5 테이블로 만들어 원본 테이블을 가리키게 하는 방법. 둘째, 원본 테이블에 AFTER INSERT, AFTER UPDATE, AFTER DELETE 트리거를 걸어 변경 사항을 FTS 테이블에 그대로 반영하는 방법입니다. 외부 콘텐츠 패턴을 쓰면 본문 텍스트를 두 번 저장하지 않아도 돼서 용량 면에서 유리합니다.

FTS5 검색 결과는 어떻게 정렬하나요?

FTS5 테이블에는 숨겨진 rank 컬럼이 있고, 여기에 BM25 점수가 들어 있습니다(값이 작을수록 더 관련성 높음). 그냥 ORDER BY rank로 정렬하면 됩니다. 점수를 직접 다루고 싶다면 bm25(table) 함수를 호출하거나, bm25(posts, 10.0, 1.0)처럼 컬럼별 가중치를 줘서 본문보다 제목 매칭에 더 높은 점수를 주는 식으로 튜닝할 수 있습니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기