Bldev's Blog

[검색] SQLite FTS5 trigram

2026. 10. 8.

아이폰용 마크다운 편집기 개발 도중 사용자가 미리 저장해 둔 참고 문서를 근거로 LLM이 글을 이어 쓰거나 수정하는 기능을 넣으면서 참고 문서를 SQLite FTS5로 검색하는 비교적 가벼운 방식의 구현을 테스트했다. 설계 단계에서 한국어에는 트라이그램(trigram) 토크나이저를 사용을 계획하였으나 몇몇 한계를 확인할 수 있었다. 트라이그램 토크나이저는 모든 글자 위치에서 세 글자씩 겹쳐 자르는 방식을 사용한다. 트라이그램이 한국어에서 무엇을 놓치는지, 대안은 무엇일지 정리해보았다.

개념

FTS5와 토크나이저

FTS5는 SQLite의 전문 검색(full-text search) 모듈이다. 글을 미리 잘게 나눈 조각인 토큰(token)의 목록을 색인(index)으로 만들어 두고, 검색어 토큰이 포함된 행을 바로 찾는다. 책 뒤의 색인 목록을 찾아서 원문을 찾는 것과 같은 원리이다. 원문 글을 어떻게 토큰으로 나눌지를 바로 토크나이저(tokenizer)가 정한다. 언어에 따라 토큰을 어떻게 나누는지는 그 방법과 원리가 매우 다양하므로 토크나이저도 그 종류가 다양하다. 동일한 단어도 토큰을 얼마나 잘게 분리할지에 따라 검색 성능이 좌우된다.

토크나이저 나누는 방식
unicode61 공백과 부호에서 자른다. 단어 하나가 토큰 하나다. 기본값이다
trigram 모든 글자 위치에서 세 글자씩 겹쳐 자른다. SQLite 3.34부터 지원한다. (Enhanced FTS5 to support trigram indexes)

한국어에서 문제가 되는 것

한국어는 어근 뒤에 조사가 붙어 한 덩어리가 된다("문서", "문서를", "문서가"). unicode61은 공백에서만 자르므로 "문서를" 전체가 토큰 하나다. 그래서 "문서"로 검색하면 걸리지 않는다.

검색 결과
"문서" 0건
"문서를" "문서를"이 든 행만
문서* (접두 검색) "문서를"과 "문서가"가 든 행 모두

접두 검색으로 어근과 조사를 잇는 우회가 가능하지만 어근이 단어의 맨 앞에 있을 때만 걸리고("참조문서를"에서는 "문서*"가 걸리지 않는다) 검색어에서 어근을 앱이 알아야 한다. 어근을 정확히 떼는 형태소 분석기를 앱에 넣는 방법도 있지만 사전을 함께 실어야 해서 이번에는 고르지 않았다.

trigram은 사전 없이 부분 문자열로 찾을 수 있다는 점 때문에 한국어에 자주 권해진다. 세 글자 이상의 어떤 부분 문자열이든 색인의 조각으로 들어 있기 때문이다.

트러블슈팅

실험은 서로 다른 문장 셋을 trigram 표와 두 글자 겹침 표에 각각 넣고 같은 검색어를 던지는 방식으로 했다.

  1. 참조 문서를 먼저 읽고 요약한다.
  2. 사용자는 문서가 길면 일부만 검색한다.
  3. 색인은 가져올 때 한 번만 만든다.

문제 1. 두 글자 단어가 한 건도 걸리지 않는다

  • 증상: MATCH '"문서"', "검색", "색인"이 모두 0건이다. 오류 없이 빈 결과가 나온다.
  • 원인: trigram 색인에는 세 글자 조각만 들어 있다. 세 글자 미만의 검색어는 맞출 조각이 없다. FTS5 문서에도 "Substrings consisting of fewer than 3 unicode characters do not match any rows when used with a full-text query."라고 적혀 있다.
  • 확인: LIKE '%문서%'는 같은 표에서 1, 2번을 찾았다. 전체를 훑으면 찾아지지만 색인의 속도도, 순위(BM25)도 쓸 수 없다.

문제 2. 조사가 다르면 서로 찾지 못한다

  • 증상: "문서를"로 검색하면 1번만, "문서가"로 검색하면 2번만 나온다.
  • 원인: "문서를"은 세 글자 조각 하나이고, 그 조각이 그대로 든 문서만 걸린다. "문서가"가 든 문서에는 "문서가"라는 다른 조각이 있을 뿐 "문서를"은 없다.
  • 세 글자 어근도 같다. 어근 "컴퓨터"만 검색하면 조사가 무엇이든 걸리지만, "컴퓨터를"처럼 조사가 붙은 채로 검색하면 같은 조사의 문서만 걸린다. "컴퓨터를", "컴퓨터가", "컴퓨터는"이 각각 든 문서와 관련 없는 문서 하나로 같은 실험을 했다.
검색어 trigram 두 글자 겹침
컴퓨터 1, 2, 4번 모두 1, 2, 4번 모두
컴퓨터를 1번만 1, 2, 4번 (1번이 가장 위)
컴퓨터가 2번만 2, 4, 1번 (2번이 가장 위)
컴퓨터는 4번만 4, 2, 1번 (4번이 가장 위)

검색어를 사용자가 쓴 글에서 자동으로 뽑으면 조사가 붙은 말이 그대로 검색어가 된다. 어근을 떼는 분석기가 없으니 이 문제는 그대로 남는다.

해결. 글을 두 글자씩 겹쳐 직접 잘라서 unicode61로 색인한다

토크나이저 대신 색인에 넣기 전에 앱에서 글을 잘라 둔다. 실험에 쓴 최소 버전이다.

static func bigrams(_ text: String) -> String {
    text.split(whereSeparator: { $0.isWhitespace || $0.isPunctuation }).flatMap { word -> [String] in
        let chars = Array(word)
        if chars.count <= 2 { return [String(word)] }
        return (0 ..< chars.count - 1).map { String(chars[$0 ... $0 + 1]) }
    }.joined(separator: " ")
}

"문서를"은 "문서 서를"로 저장된다. 검색어도 같은 함수로 자른 뒤 조각마다 큰따옴표로 감싸 OR로 잇는다.

"문서를" → "문서" OR "서를"

실제 구현은 한글·한자·가나 구간만 이렇게 자르고, 영문과 숫자는 소문자로 바꾼 낱말 하나를 토큰으로 둔다. 영문에는 어간 처리가 없다. sqlite3 3.54.0에서 "indexed"는 "indexed"가 든 행을 찾고 "index"는 0건이다.

발견

결과

문장 셋(위와 같다)에서 같은 검색어를 두 방식으로 던졌다.

검색 trigram 두 글자 겹침
"문서를" 1번 1, 2번
"문서가" 2번 2, 1번
"문서" 0건 1, 2번
"검색" 0건 2번
"색인" 0건 3번
LIKE '%문서%' (trigram 표) 1, 2번 -

두 글자 단어도, 조사가 다른 말도 두 글자 겹침에서는 걸린다. 같은 실험을 macOS의 SQLite 3.54.0과 iOS 시뮬레이터(iOS 26.1)의 SQLite 3.51.0에서 돌려 결과가 같았다.

바꾸고 나서 만난 함정 둘

풀어 쓴 한글과 붙여 쓴 한글. macOS에서 가져온 파일 이름은 한글이 자모로 풀어진 형태(NFD)인 경우가 있다. Swift의 String ==는 두 형태를 같은 글로 본다. 그래서 "풀어 쓴 입력과 붙여 쓴 입력이 같은 토큰이 된다"는 시험을 ==로 쓰면, 구현이 정규화를 빼먹어도 통과한다. SQLite는 바이트로 비교하므로 실제로는 서로 찾지 못한다.

  • 해결: 토큰으로 자르기 전에 precomposedStringWithCanonicalMapping으로 정규화하고, 시험은 유니코드 스칼라 배열로 비교한다.
  • 확인: 정규화를 지우는 변이를 넣었더니 고친 시험이 실패했다.

검색 문법 문자. 사용자 글에 ", OR, NEAR, *, -, :가 들어 있으면 FTS5 MATCH 식이 깨지거나 연산자로 읽힐 수 있다. 토큰을 모두 큰따옴표로 감싸고, 큰따옴표는 토큰 구분자로 취급해 토큰 안에 들어오지 못하게 했다. 문법 문자가 든 질의 14개로 시험해 오류가 나지 않음을 확인했다.

대가

  • 엉뚱한 곳이 걸릴 수 있다. 조사만 다른 문서도 걸리고("컴퓨터를"로 찾았는데 2, 4번도 나온다), 다른 단어 속의 우연한 조각("순서를"의 "서를")도 걸릴 수 있다. BM25가 겹치는 조각 수로 순위를 매겨 위아래를 가르지만, 얼마나 정확한지는 재지 않았다.
  • 색인에 글자마다 조각이 생기므로 단어만 색인할 때보다 커진다. 크기는 재지 않았다.
  • 어근과 조사를 나누지는 못한다. 정밀하게 하려면 형태소 분석이 필요하다.

고르는 기준

방식 장점 한계
unicode61만 가장 단순 "문서"로 "문서를"을 못 찾는다. 접두 검색은 어근이 맨 앞일 때만 된다
trigram 사전 없이 부분 문자열 검색, 대소문자 무시, BM25 세 글자 미만 불가, 조사가 다르면 불가, 색인이 크다
두 글자 겹침 + unicode61 두 글자 단어와 조사 차이를 흡수 엉뚱한 조각이 걸릴 수 있다

한국어를 검색하면서 형태소 분석기를 넣기 어렵다면 세 가지를 먼저 따져 본다. 두 글자 단어를 찾아야 하는가, 조사가 붙은 말로 검색하는가, 순위가 필요한가. 하나라도 그렇다면 겹쳐 자른 토큰을 unicode61로 색인하는 쪽이 이 실험에서는 더 잘 맞았다.

재현

sqlite3 셸(3.34 이상)에서 그대로 돌릴 수 있다. 두 번째 표의 저장 글은 위 bigrams 함수로 자른 결과다.

create virtual table tri using fts5(body, tokenize='trigram');
insert into tri(rowid, body) values
  (1, '참조 문서를 먼저 읽고 요약한다.'),
  (2, '사용자는 문서가 길면 일부만 검색한다.'),
  (3, '색인은 가져올 때 한 번만 만든다.');
select rowid from tri where tri match '"문서를"';  -- 1
select rowid from tri where tri match '"문서"';    -- (없음)
select rowid from tri where body like '%문서%';    -- 1, 2

create virtual table bi using fts5(body, tokenize='unicode61');
insert into bi(rowid, body) values
  (1, '참조 문서 서를 먼저 읽고 요약 약한 한다'),
  (2, '사용 용자 자는 문서 서가 길면 일부 부만 검색 색한 한다'),
  (3, '색인 인은 가져 져올 때 한 번만 만든 든다');
select rowid from bi where bi match '"문서" OR "서를"';  -- 1, 2
select rowid from bi where bi match '"검색"';            -- 2
select rowid from bi where bi match '"색인"';            -- 3

확인하지 못한 것

  • 검색 순위의 품질(정답 조각이 상위에 오는 비율)은 재지 않았다. 이 글의 결론은 "찾아진다"까지다.
  • 색인 크기와 검색 속도는 재지 않았다.
  • iOS 17의 SQLite에서는 돌려 보지 않았다. 시뮬레이터는 iOS 26.1 하나뿐이었다.
  • 영어와 한국어가 섞인 긴 문서에서의 노이즈는 보지 않았다.