[검색] SQLite FTS5 한국어 검색: trigram과 bigram
2026. 10. 8.
마크다운 편집기 개발 도중 사용자가 미리 저장해 둔 참고 문서를 근거로 LLM이 글을 이어 쓰거나 수정하는 기능을 넣으면서 참고 문서를 SQLite FTS5로 검색하는 비교적 가벼운 방식의 구현을 테스트했다. 설계 단계에서 한국어에는 문자 수준 트라이그램(trigram)인 trigram 토크나이저(tokenizer) 사용을 계획하였으나 몇몇 한계를 확인할 수 있었다. 트라이그램 토크나이저는 단어의 경계(공백)와 무관하게 세 글자씩 겹쳐 자르는 방식을 사용한다. trigram이 한국어에서 무엇을 놓치는지, 대안은 무엇일지 정리해보았다.
FTS5와 토크나이저
FTS5는 SQLite의 전문 검색(full-text search) 모듈이다. 글을 미리 잘게 나눈 조각인 토큰(token)의 목록을 색인(index)으로 만들어 두고 검색어 토큰이 포함된 행을 찾는 검색 기능을 수행한다. 책 뒤의 색인 목록을 찾아서 원문을 찾는 것과 같은 원리이다. 원문 글을 어떻게 토큰으로 나눌지를 바로 토크나이저(tokenizer)가 정한다. 언어에 따라 토큰을 어떻게 나누는지는 그 방법과 원리가 매우 다양하므로 토크나이저도 그 종류가 다양하다. 동일한 단어도 토큰을 얼마나 잘게 분리할지에 따라 검색 성능이 좌우된다.
| 토크나이저 | 나누는 방식 |
|---|---|
unicode61 |
공백과 문장 부호/구두점에서 분리한다. 단어 하나가 토큰 하나다. 기본값이다. |
trigram |
모든 글자 위치에서 세 글자씩 겹쳐 분리한다. SQLite 3.34부터 지원한다. (Enhanced FTS5 to support trigram indexes) |
한국어에서 문제가 되는 것
한국어는 어근 뒤에 조사가 붙어 한 덩어리가 된다("문서", "문서를", "문서가"). unicode61은 공백을 기준으로 자르므로 "문서를" 전체가 토큰 하나다. 그래서 "문서"로 검색하면 검색되지 않는다.
| 검색 | 결과 |
|---|---|
| "문서" | 0건 |
| "문서를" | "문서를"이 든 행만 |
| "문서*" (접두어 검색) | "문서를"과 "문서가"가 든 행 모두 |
접두어(prefix) 검색으로 어근과 조사를 잇는 우회가 가능하지만 어근이 단어의 맨 앞에 있을 때만 검색되고("참조문서를"에서는 "문서*"가 검색되지 않는다) 검색어에서 어근을 알아야 한다. 어근을 정확히 분리하는 형태소 분석기를 사용하는 방법도 있지만 사전이 함께 필요하다.
trigram은 사전 없이 부분 문자열로 찾을 수 있다는 점 때문에 한국어에서 자주 사용된다. 세 글자 이상의 어떤 부분 문자열이든 색인의 토큰으로 포함되기 때문이다.
아래의 테스트 문장 3개를 trigram 색인을 위한 가상 테이블 tri와, 두 글자 겹쳐 자르는 바이그램(bigram) 색인을 위한 가상 테이블 bi에 각각 저장한 뒤 동일한 검색어로 질의해 동작을 비교해 보았다.
- 참조 문서를 먼저 읽고 요약한다.
- 사용자는 문서가 길면 일부만 검색한다.
- 색인을 가져올 때 한 번만 만든다.
두 글자 단어는 한 건도 검색되지 않는다
tri 테이블에서 MATCH '"문서"', "검색", "색인" 실행 결과 모두 0건으로 오류 없이 빈 결과가 나온다. tri 테이블의 색인에는 세 글자 토큰만 들어 있으며 세 글자 미만의 검색어는 매칭할 토큰이 없기 때문이다. 이는 정상적인 동작이다. FTS5 문서에 따르면 FTS5 trigram 토크나이저는 3글자 이상의 연속된 문자만 색인 토큰으로 생성하므로 1~2글자 검색어는 토큰이 존재하지 않아 매칭 결과가 0건으로 반환된다.
Substrings consisting of fewer than 3 unicode characters do not match any rows when used with a full-text query.
tri 테이블에서 LIKE '%문서%' 실행 결과 1, 2번이 검색된다. trigram 테이블에서는 LIKE/GLOB 패턴 중 연속된 비와일드카드 글자가 3자 이상이면 trigram 색인으로 후보를 좁힐 수 있지만, 2자 이하이면 색인을 쓰지 못해 전체 행을 훑는 풀 스캔(full table scan)이 된다. 또한 LIKE는 MATCH가 아니므로 FTS5의 내장 랭킹 함수인 bm25()를 활용한 관련도 정렬은 불가능하다.
FTS5 문서에는 LIKE/GLOB 사용 시 알아둘 제약도 있다. case_sensitive 1 옵션으로 만든 trigram 테이블은 LIKE가 아니라 GLOB만 색인을 쓸 수 있고, LIKE에 ESCAPE 절이 있으면 색인 최적화가 적용되지 않는다. 색인을 LIKE/GLOB 용도로만 쓴다면 detail=none 또는 detail=column 옵션으로 색인 크기를 줄일 수 있다(이 경우 전문 검색 질의의 토큰 길이에 제한이 생긴다).
조사가 다르면 서로 찾지 못한다
1번 문장에는 "문서를", 2번 문장에는 "문서가"가 포함되어 있다. tri 테이블에서 "문서를"로 검색하면 1번만 검색되고 `문서가"로 검색하면 2번만 검색된다. "문서를"은 세 글자 토큰 하나이며 그 토큰이 그대로 포함된 문서만 검색되기 때문이다. 같은 어근("문서")을 공유하더라도 조사가 다르면 색인 토큰 자체가 완전히 달라진다. 어근이 세 글자 이상인 단어라면 어근만 검색해 조사의 차이를 피할 수 있지만 사용자가 조사까지 포함해 검색할 경우 다른 조사가 붙은 문장은 찾을 수 없다.
| 검색어 | trigram |
|---|---|
| "문서를" | 1번만 |
| "문서가" | 2번만 |
| "문서" | 0건 (3글자 미만) |
검색어를 사용자가 입력한 본문에서 그대로 추출해 질의로 던질 경우 조사가 붙은 형태가 그대로 검색어가 된다. 어근을 분리하는 분석기가 없으면 trigram에서도 이 문제가 해결되지 않는다.
trigram은 4글자 이상의 검색어가 들어오면 이를 연속된 트라이그램 토큰들의 인접 구문(phrase) 매칭으로 처리한다. 예를 들어 사용자가 "데이터를"(4글자)로 질의하면 내부적으로 "데이터" 바로 뒤에 "이터를"이 이어지는 문서만 일치로 판정한다. 따라서 본문에 어근이 같은 "데이터가"("데이터" + "이터가")가 있더라도 후속 토큰이 달라 매칭되지 않는다. 결국 어근의 길이가 3글자 이상이더라도 검색어 끝에 조사가 포함되어 있다면 다른 조사가 붙은 문장은 구조적으로 찾을 수 없다는 한계가 있다.
두 글자씩 겹쳐 직접 잘라서 unicode61로 색인
SQLite에는 한국어를 두 글자씩 겹쳐 자르는 바이그램(bigram) 토크나이저가 따로 없다. 별도의 C 확장 토크나이저를 빌드해 등록하는 대신 FTS5 가상 테이블 bi에 저장하기 전 앱 레벨에서 본문을 2글자 단위로 잘라두는 방식을 사용한다. 실험에 사용한 최소 구현 코드는 다음과 같다.
static func bigrams(_ text: String) -> String {
text.split(whereSeparator: { $0.isWhitespace || $0.isPunctuation }).flatMap { word -> [String] in
let chars = Array(word)
// 1~2글자 단어는 그대로 유지 (단, 1글자 단어는 노이즈 방지를 위해 필터링 고려 필요)
if chars.count <= 2 { return [String(word)] }
return (0 ..< chars.count - 1).map { String(chars[$0 ... $0 + 1]) }
}.joined(separator: " ")
}
"문서를"은 "문서", "서를"로 저장된다. 검색어도 같은 함수로 자른 뒤 조각마다 큰따옴표로 감싸 OR로 연결한다.
"문서를" → "문서" OR "서를"
실제 프로덕션 구현에서는 조사가 결합되는 한글, 한자, 가나 등 CJK 문자만 이 방식으로 분해하고 영문과 숫자는 소문자로 정규화한 단어 단위로 색인한다.
단음절(1글자) 처리 시 주의할 점은 chars.count <= 2 분기에서 1글자 단어도 그대로 반환된다. unicode61은 1글자 토큰도 정상적으로 색인하지만 단독 조사("이", "가", "를")나 빈번한 단음절 명사가 검색어에 포함될 경우 검색 노이즈를 유발할 수 있다. 1글자 토큰을 색인 대상에서 제외하거나 최소 길이 검증(2글자 이상)을 두는 방식의 방어 로직이 안전하다.
검색 결과 비교
앞서 제시한 문장 셋(1~3번)을 대상으로 동일한 검색어를 두 방식에 질의한 결과다.
| 검색 | trigram |
두 글자 겹침 + unicode61 |
|---|---|---|
| "문서를" | 1번 | 1, 2번 |
| "문서가" | 2번 | 2, 1번 |
| "문서" | 0건 | 1, 2번 |
| "검색" | 0건 | 2번 |
| "색인" | 0건 | 3번 |
LIKE '%문서%' |
1, 2번 | - |
두 글자 단어는 물론, 조사가 서로 다른 단어도 두 글자 겹침 방식에서는 검색된다. 다만 위 결과는 조각을 OR로 연결했기 때문에 조각 하나만 맞아도 검색되는 것이며, "문서를" 검색에 "문서가"만 든 2번이 걸리는 것도 이 때문이다. 조각을 "문서 서를"처럼 하나의 구(phrase)로 묶거나 AND로 연결하면 같은 데이터에서 1번만 검색된다. 즉 재현율(recall)과 정밀도(precision) 사이에서 질의 구성으로 균형을 정해야 한다.
실제 말뭉치에서 질의 방식별 정밀도와 재현율을 측정한 결과다.
- 포스트 방식(bigram OR)만 정밀도 0.47이다. 같은 색인에서 질의를 단어별 구(phrase) + 조사 제거로 바꾸면 정밀도·재현율 모두 1.00이다.
- trigram은 어근(3자 이상)을 알면 정밀도·재현율 모두 1.00이지만 조사가 붙은 그대로 검색하면 재현율 0.30이다.
- OR 노이즈의 원인은 조사·어미 조각의 문서 빈도다. "한다"는 3,957개 문단 중 1,886개에 들어 있다.
측정 상세 펼치기접기
환경
- SQLite
- 3.45.1 (Python 3 내장, Linux)
- 말뭉치
- 블로그 한국어 포스트 208편을 문단 단위로 나눈 3,957개 문서 (평균 206자)
- bigram 함수
- 포스트의 Swift bigrams 를 Python 으로 동일 로직 이식
- 질의
- 말뭉치에서 뽑은 3~5자 조사 결합 명사 150개 (문서 빈도 3~40)
- 정답
- 조사를 뗀 어근을 부분 문자열로 포함하는 문단
결과비율, 조사 결합 명사 150개
측정하지 않은 것과 한계
- Swift Character 분할 동작은 검증하지 못했다.
- 기술 블로그 말뭉치라 어휘가 편향되어 있다. 수치는 1회 실행 값이다.
- 정답을 부분 문자열 포함으로 정의해 음절 우연 일치는 정답에 넣지 않았다.
재현
말뭉치를 trigram, bigram+unicode61 색인으로 만든 뒤 150개 질의를 6가지 방식으로 실행해 정답 집합과 비교구현 시 주의할 점
유니코드 정규화 (NFD와 NFC)
macOS 등에서 전달된 텍스트는 한글 자모가 분리된 형태(NFD)인 경우가 있다. 스위프트의 String 동등 비교(==)는 유니코드 표준 동등성(canonical equivalence)을 따르므로 두 형태를 같은 문자열로 인식한다. 따라서 NFD와 NFC 입력을 단순 == 연산자로 비교하면 정규화 처리가 누락되어도 단위 테스트를 통과해 버린다. 반면 SQLite는 바이트 단위로 텍스트를 비교하므로 정규화하지 않으면 서로 매칭되지 않는다.
해결 방법은 토큰으로 자르기 전에 precomposedStringWithCanonicalMapping으로 정규화하고 테스트는 유니코드 스칼라 배열로 비교하는 것이다. 정규화 로직을 의도적으로 제거했을 때 테스트가 정상적으로 실패함을 확인할 수 있다.
FTS5 특수 쿼리 문법 처리
사용자 입력에 ", OR, NEAR, *, -, : 등의 특수 기호가 포함되면 FTS5 구문 분석기가 이를 연산자로 인식해 쿼리 문법 오류가 발생할 수 있다.
해결 방법은 분리된 각 토큰을 큰따옴표(")로 감싸고, 입력값 내부의 큰따옴표는 구분자로 취급해 토큰 생성 시 제외하는 것이다. 특수 문자가 포함된 다양한 질의를 입력해도 문법 오류 없이 안전하게 실행되는 것을 확인할 수 있다.
트레이드오프
조사가 다른 문서가 함께 검색되거나("문서를" 검색 시 "문서가" 포함 문서도 노출), 다른 단어의 일부 음절이 우연히 겹쳐("순서를"의 "서를") 검색 결과에 포함될 수 있다. BM25 점수는 일치하는 조각의 수와 희소성을 반영하므로 대체로 일치 조각이 많은 문서가 앞서지만, 항상 의도한 문서가 상위에 오른다고 보장할 수는 없고 검색 정밀도가 떨어질 수 있다.
토크나이저 선택 기준
| 방식 | 장점 | 한계 |
|---|---|---|
unicode61 단독 |
구현이 가장 단순함 | "문서"로 "문서를"을 찾지 못함. 접두 검색은 어근이 맨 앞일 때만 동작함 |
trigram |
사전 없이 부분 문자열 검색 가능, 대소문자 무시, BM25 지원 | 세 글자 미만 검색 불가(LIKE는 가능하나 풀 스캔), 조사가 다르면 검색 불가 |
두 글자 겹침 + unicode61 |
두 글자 단어 및 조사 차이를 흡수함 | 검색 결과에 노이즈(부분 일치)가 포함될 수 있음, 원문 별도 저장 필요, 한 글자 검색 불가 |
외부 형태소 분석기 라이브러리를 추가하기 어려운 환경이라면 다음 세 가지를 고려해야 한다.
- 두 글자 단어를 검색해야 하는가?
- 조사가 결합된 상태로 검색 질의가 들어오는가?
- 관련도 순위(BM25) 정렬이 필요한가?
이 중 하나라도 해당한다면, 두 글자 단위로 전처리한 뒤 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
한계 및 고려할 점
BM25 랭킹을 통해 의도한 문서가 항상 최상위에 오르는지에 대한 검색 품질 평가는 데이터셋에 따라 추가 검증이 필요하다.