2026년 8월 15일 · 9분 읽기
SQLite FTS5 한국어 전문검색: trigram 토크나이저와 2글자 LIKE 폴백
GRDB와 SQLite FTS5로 한국어 부분 일치 검색을 만들면서 unicode61에서 trigram으로 옮긴 기록입니다. 세 글자 미만 질의가 인덱스에 없어 남는 LIKE 경로까지 다룹니다.
2026년 8월 23일 업데이트
커넥션풀이라고 저장한 메모를 풀로 검색했습니다. 안 나왔습니다.
SQLite의 FTS5는 기본 토크나이저로 unicode61을 씁니다. 공백으로 자른 토큰의 앞만 색인합니다. 커넥션풀은 하나의 토큰이고, 그 토큰의 접두사만 잡히므로 커넥은 찾아도 풀은 못 찾습니다.
한국어에서는 이 구멍이 큽니다. 조사가 붙고 복합명사가 붙습니다. 회의록에서, 프로젝트관리처럼 씁니다. 앞만 보는 색인은 절반만 찾습니다.
한눈에 보기
| 문제 | 커넥션풀이라고 저장한 메모를 풀로 검색했습니다. |
| 결정 | 같은 마이그레이션에서 trigram 색인을 여섯 곳에 만들었습니다. |
| 결과 | 엔터까지 미뤘던 것을 되돌렸습니다. |
| 제약 | 마지막 줄도 필요한 확인입니다. |
구멍을 LIKE로 메우면 키 입력마다 테이블을 훑습니다
첫 해법은 단순했습니다. FTS로 못 찾는 것은 LIKE '%풀%'로 찾으면 됩니다.
동작은 합니다. 문제는 %가 앞에 붙은 LIKE는 인덱스를 쓸 수 없다는 것입니다. 테이블을 처음부터 끝까지 읽습니다.
우리는 다섯 갈래의 본문을 검색합니다. 제목, 원문, 완료 메모, 요약, 정리본입니다. 여기에 첨부의 파일명과 OCR 텍스트, 문서 쪽 텍스트, 주석, 음성 전사, 링크 본문이 더 있습니다.
키를 한 번 누를 때마다 이 테이블들을 통째로 읽었습니다.
그래서 실시간 검색을 포기했습니다
타이핑할 때마다 검색하면 앱이 멈춥니다. 그래서 엔터를 누를 때까지 검색을 미뤘습니다.
기능을 줄여서 성능 문제를 덮은 것입니다. 사용자는 검색어를 다 입력하고 엔터를 눌러야 결과를 봅니다. 두세 글자만 쳐도 원하는 것이 나오는 경험을 포기했습니다.
주석에 이 인과가 적혀 있습니다.
키 입력마다 세 테이블을 통째로 훑는 비용이 검색을 엔터까지 미루게 만든 원인이다.
성능 문제를 기능 축소로 덮으면 원인이 그대로 남습니다.
trigram은 3글자 창을 전부 색인합니다
FTS5에는 trigram 토크나이저가 있습니다. 텍스트를 세 글자씩 겹쳐 자릅니다.
커넥션풀은 이렇게 색인됩니다.
커넥션 / 넥션풀풀을 찾을 때 넥션풀에 포함되므로 인덱스로 잡힙니다. 부분 일치가 인덱스 조회가 됩니다.
GRDB에서는 이렇게 선언합니다.
// AppDatabase.swift:1923-1933 (v93 마이그레이션)
let trigram = FTS5TokenizerDescriptor(components: ["trigram"])
try db.dropFTS5SynchronizationTriggers(forTable: "item_fts")
try db.drop(table: "item_fts")
try db.create(virtualTable: "item_fts", using: FTS5()) { t in
t.synchronize(withTable: "item") // 원본 테이블과 자동 동기화
t.tokenizer = trigram
t.column("title")
t.column("rawInputText")
t.column("completionNote")
}
synchronize(withTable:)가 트리거를 만듭니다. item에 행이 들어오거나 바뀌면 인덱스가 따라옵니다. 코드에서 인덱스를 갱신하는 자리를 만들지 않아도 됩니다.
인덱스는 external content 방식입니다. 원본 테이블이 정본이고 인덱스는 파생입니다. 인덱스가 깨지면 원본에서 다시 만들 수 있습니다.
두 토크나이저를 같은 질의로 놓고 비교하면 이렇습니다.
| 질의 | unicode61 |
trigram |
|---|---|---|
커넥 (토큰 앞) |
적중 | 적중 |
션풀 (토큰 중간) |
미적중 | 적중 |
커넥션풀 |
적중 | 적중 |
| 2자 질의 | 적중 | 미적중, 훑기로 넘김 |
풀* 접두 표시 |
뜻이 있음 | 아무 일도 안 함 |
| 저장 공간 | 작음 | 3글자 창 전부라 큼 |
마지막 두 줄이 바꾸면서 잃은 것입니다. 접두 표시가 뜻을 잃으니 질의를 만드는 코드도 바꿔야 하고, 3글자 창을 전부 색인하니 인덱스가 커집니다. 얼마나 커지는지는 재지 않았습니다.
2자 질의를 잃은 것은 훑기로 메웁니다. 그때도 대상이 후보로 좁혀진 뒤입니다.
3자는 잡히고 2자는 안 잡힙니다
trigram의 이름 그대로 세 글자입니다. 두 글자는 색인에 존재하지 않습니다.
SQLite 3.54에서 실측했습니다. 3자 MATCH는 적중하고 2자는 미적중입니다.
// SearchQueryNormalization.swift:62-63
/// trigram은 3글자 창을 색인한다 — 그보다 짧은 낱말은 인덱스에 존재하지 않는다.
static let trigramMinimumLength = 3
이 사실을 코드가 알고 있어야 합니다. 두 글자 질의를 MATCH에 넣으면 조용히 0건이 나옵니다.
접두 표시는 붙이지 않습니다
FTS5에서 풀*처럼 별표를 붙이면 접두사 검색이 됩니다. unicode61에서는 유용합니다.
trigram에서는 다릅니다.
// SearchQueryNormalization.swift:33-36
/// 접두 표시(`*`)는 붙이지 않는다: trigram에서 그것은 아무 일도 하지 않고
/// (2자 접두 질의 실측 0건), 따옴표 안의 낱말 자체가 이미 **부분 일치**로 동작한다.
trigram은 이미 부분 일치입니다. 별표는 아무 일도 하지 않습니다. 그리고 2자 접두 질의는 실측 0건입니다.
토크나이저를 바꿀 때 질의 문법도 함께 봐야 합니다.
질의를 인덱스가 받는 것과 훑을 것으로 가릅니다
두 글자 검색을 포기할 수는 없습니다. AI, 회의 같은 말을 사용자가 씁니다.
그래서 질의를 둘로 나눕니다.
// SearchQueryNormalization.swift:11-15
/// 색인은 trigram이라 **3글자부터** 인덱스로 잡히므로, 질의는 스스로를 둘로
/// 가른다: 인덱스가 받는 토큰(`ftsExpression`)과 그러지 못해 훑어야 하는
/// 나머지(`residualLikePatterns`). 둘을 합치면 언제나 원래 토큰 전부다 —
/// 한쪽만 쓰면 질의의 일부가 조용히 사라진다.
마지막 문장이 이 설계의 안전장치입니다. 두 목록의 합집합이 원래 토큰과 같아야 합니다.
| 질의 | 인덱스가 받는 것 | 훑을 것 |
|---|---|---|
커넥션풀 설정 |
커넥션풀, 설정 |
없음 |
AI 요약 |
요약 |
AI |
회의 록 |
없음 | 회의, 록 |
세 번째 줄이 예전의 느린 경로입니다. 다만 이제는 다릅니다. 색인이 후보를 먼저 좁히고, 그 후보 위에서만 짧은 토큰을 확인합니다.
전체 테이블을 훑는 것과 후보 몇십 건을 확인하는 것은 다른 비용입니다.
FTS 연산자를 호출자에게서 받지 않습니다
사용자가 검색창에 title:회의나 AI OR 요약을 입력할 수 있습니다. 이것을 그대로 FTS 질의에 넣으면 FTS5 문법으로 해석됩니다.
// SearchQueryNormalization.swift:33-39
/// FTS 연산자는 호출자에게서 받지 않는다 — 토큰을 통째로 따옴표에 넣어
/// `title:`이나 `OR` 같은 글자도 평범한 검색어가 되게 한다.
public var ftsExpression: String? {
let searchable = trigramTokens.map { "\"\($0)\"" }
return searchable.isEmpty ? nil : searchable.joined(separator: " AND ")
}
토큰마다 따옴표로 감쌉니다. OR는 연산자가 아니라 "OR라는 낱말"이 됩니다.
검색 문법을 사용자에게 열어 주는 것은 별도의 기능입니다. 열지 않기로 했으면 문법 문자가 새어 들어가지 않게 막아야 합니다.
색인 대상을 여섯 테이블로 넓혔습니다
같은 마이그레이션에서 trigram 색인을 여섯 곳에 만들었습니다.
| 테이블 | 색인한 컬럼 |
|---|---|
item |
제목, 원문, 완료 메모 |
item_attachment |
파일명, OCR 텍스트 |
item_attachment_page |
쪽 텍스트 |
annotation |
주석 본문 |
voice_transcript_segment |
음성 전사 |
link_extract |
링크 본문 markdown |
사진 속 글자와 음성에서 받아쓴 말이 같은 검색으로 찾아집니다. 사용자에게는 "저장한 것"이 하나이므로 검색도 하나여야 합니다.
점수를 합하지 않고 가장 강한 것만 씁니다
여러 경로가 같은 문서를 찾아옵니다. 제목에서도 걸리고 OCR 텍스트에서도 걸립니다.
점수를 어떻게 합칠지가 결과 순서를 결정합니다. 처음 생각은 더하는 것입니다. 여러 곳에서 걸렸으니 더 관련 있어 보입니다.
틀렸습니다.
// SearchEngine.swift:139-142
// 여러 검색 경로가 같은 문서를 되찾았다는 사실은 관련도가 아니다.
// 가장 강한 실제 필드 일치만 점수로 삼아, 필드가 많거나 최신이라는 이유로
// 정확 제목을 덮지 못하게 한다.
lexicalScore = max(lexicalScore, lexicalContribution)
max입니다. 합이 아닙니다.
더하면 필드가 많은 문서가 유리해집니다. 첨부가 열 개 있는 메모는 걸릴 자리가 열 곳입니다. 제목이 검색어와 정확히 같은 메모를 그 문서가 덮습니다.
사용자가 회의록을 검색했을 때 원하는 것은 제목이 회의록인 메모입니다. 첨부 파일명에 회의록이 열 번 나오는 다른 메모가 아닙니다.
bm25는 MATCH와 같은 질의 안에 있어야 합니다
FTS5의 bm25() 함수가 관련도 순위를 계산합니다. 제약이 하나 있습니다.
// SearchEngine.swift:358-360
// 색인이 걸릴 때만 `JOIN`으로 붙인다 — bm25는 MATCH가 같은 질의 안에
// 있어야 부를 수 있고, 그 순위가 상한(`sourceLimit`)에 걸릴 때 무엇을
// 남길지 정한다. 짧은 토큰뿐이면 색인이 없으므로 최신순으로 훑는다.
MATCH가 없는 질의에서는 bm25()를 부를 수 없습니다. 그래서 두 글자 질의처럼 색인을 못 쓰는 경우에는 관련도 순위가 없습니다. 최신순으로 갑니다.
각 소스마다 후보 상한이 있고, 최종 결과 상한은 40건입니다. 상한에 걸릴 때 무엇을 버릴지가 순위로 결정됩니다.
2026-08-23 확인: 의미 검색 경로는 현재 비활성입니다
검색 엔진에는 어휘 점수와 의미 점수를 합치는 구조가 남아 있습니다. 그러나 한국어 경로가 사용하는 NLEmbedding.wordEmbedding(for: .korean)은 실제 기기에서 지원되지 않아 nil 폴백됩니다. 지금 제품에서 실질적으로 동작하는 경로는 FTS5 trigram과 짧은 질의의 제한된 LIKE 폴백입니다.
다국어 온디바이스 자연어 검색은 별도 작업으로 진행 중입니다. 구현과 실기 검증이 끝나기 전에는 의미 검색이 동작한다고 설명하지 않습니다.
의미 검색은 설계만 남아 있습니다
단어가 다르지만 뜻이 비슷한 것도 찾고 싶습니다. 회의로 검색해서 미팅이 나오면 좋습니다.
NLEmbedding으로 단어를 벡터로 바꿔 코사인 유사도를 계산합니다. 온디바이스 모델입니다.
// SearchEngine.swift:148-150
var finalScore: Float {
lexicalScore > 0 ? lexicalScore : (semanticScore ?? 0)
}
두 점수를 섞지 않습니다. 글자로 걸린 것이 있으면 그것만 씁니다. 없을 때만 의미 점수를 봅니다.
섞으면 글자가 정확히 맞는 결과가 의미상 비슷한 결과에 밀릴 수 있습니다. 사용자가 입력한 글자가 그대로 들어 있는 문서를 먼저 보여 주는 것이 예측 가능합니다.
상한과 문턱도 뒀습니다.
| 값 | 무엇 |
|---|---|
| 0.7 | 의미 일치로 인정하는 코사인 최소값 |
| 200 | 의미 검색 후보 상한, 최신 순 |
| 5 | 확장 상한 |
| 40 | 최종 결과 상한 |
0.7은 낮지 않은 값입니다. 아카이브로 옮긴 옛 문서에는 0.35로 적혀 있었는데, 소스와 작업 기록이 둘 다 0.7입니다. 문서 쪽이 낡았습니다.
수십 MB 모델을 첫 검색까지 로드하지 않습니다
NLEmbedding은 온디바이스 모델이고 로드에 동기 디스크 I/O가 듭니다. 앱을 켤 때 로드하면 실행이 느려집니다.
// SearchEngine.swift:176-182
// NLEmbedding 로드(수십 MB 온디바이스 모델, 동기 디스크 I/O)는 최초
// 실제 사용 시점까지 지연 — struct 저장 프로퍼티는 lazy 불가하므로
// 클로저가 캡처하는 박스 클래스로 1회 로드·캐시한다.
final class LazyEmbedding {
lazy var value: NLEmbedding? = NLEmbedding.wordEmbedding(for: .korean)
}
let box = LazyEmbedding()
SearchEngine이 struct라서 lazy var를 쓸 수 없습니다. 그래서 클래스 하나를 만들어 클로저가 캡처하게 했습니다. 클로저가 살아 있는 동안 박스도 살아 있고, 로드는 한 번만 일어납니다.
문장 벡터는 단어 벡터의 평균으로 만듭니다. 단어별로 벡터를 얻어 합한 뒤 개수로 나눕니다.
보관 상태를 태그에서 컬럼으로 옮겼습니다
검색과 직접 관련은 없지만 같은 코드에 흔적이 남은 결정이 있습니다.
보관한 기록은 검색에서 기본으로 빠집니다. 예전에는 보관 상태가 태그 배열 안의 예약어였습니다. 보관함이라는 태그가 붙어 있으면 보관된 것으로 봤습니다.
// SearchEngine.swift:200-203
/// 보관한 기록은 기본으로 빠진다. 예전에는 `scopeTag: "보관함"`이 그것을
/// 되돌리는 수단이었다 — 보관 상태가 태그 배열 안의 예약어였기 때문이다.
/// 상태를 컬럼으로 뺀 지금(v87) 그 낱말은 그냥 사용자 태그라서 같은 일을
/// 할 수 없고, 해서도 안 된다. **보관함 안을 찾는 것은 별도의 축이다.**
상태를 태그로 표현하면 사용자가 그 낱말을 태그로 쓸 수 없습니다. 그리고 태그를 지우면 상태가 바뀝니다.
v87에서 컬럼으로 뺐습니다. 그 뒤 보관함은 그냥 사용자 태그입니다. 보관함 안을 검색하는 것은 includeArchived 매개변수로 하는 별도의 일이 됐습니다.
상태와 사용자 데이터를 같은 자리에 두면 나중에 반드시 갈라야 합니다.
실시간 검색을 되돌렸습니다
엔터까지 미뤘던 것을 되돌렸습니다. 이제 글자가 곧 질의입니다.
검색어를 모델에 넣는 자리가 원래 하나였습니다. 엔터를 눌러 확정하는 함수입니다. 그 자리를 입력 글자가 바뀔 때마다 가게 바꿨습니다.
늦추는 일은 모델이 집니다. 140ms 동안 기다립니다. 그리고 다음 글자가 앞선 세대를 질의 전에 취소합니다. 그래서 SQLite는 버려질 검색을 돌지 않습니다.
결과는 다음 답이 올 때까지 자리를 지킵니다. 글자를 하나 더 치는 순간 화면이 비면 사용자는 결과가 사라졌다고 읽습니다.
같이 지운 것이 있습니다. 이미 로드된 200건의 제목만 훑어서 추천어를 만드는 화면이었습니다. 실제 검색이 매 글자 도는데 제목만 보는 추천을 따로 둘 이유가 없습니다. 죽은 문구 16개 로케일과 그 계약 테스트 항목도 함께 지웠습니다.
엔터를 한 번도 누르지 않고 확인했습니다
시뮬레이터에서 글자를 하나씩 쳤습니다.
| 입력 | 화면 |
|---|---|
저 |
검색 결과 1건 |
저장된 |
1건 |
저장된다 |
1건 |
ㄴㅁㅍ |
일치하는 기록이 없어요 |
세 번째 줄이 이 작업의 결과입니다. 그 기록의 제목은 AUTO SAVE TEST 수정됨입니다. 제목이 아니라 본문 안쪽 낱말로 잡혔습니다.
마지막 줄도 필요한 확인입니다. 0건이 나오는 것이 검색이 안 도는 것인지 진짜 0건인지 구분해야 합니다.
계약 테스트도 여섯 개 새로 썼습니다. 합본 텍스트가 비어도 쪽 텍스트로 찾히는지, 링크를 수집하기 전에는 0건이고 수집 후에 적중하는지, 연결재무제표에서 무제표가 잡히는지, 2자 질의가 색인 없이도 찾히는지, 마지막 키 입력만 엔진에 닿는지, 앞선 결과가 다음 키 입력에서 살아남는지입니다.