본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 09. 12:51

SQLite FTS5의 기본 토크나이저가 일본어 부분 문자열을 누락하는 이유 (그리고 한 줄로 해결하는 방법)

요약

SQLite FTS5의 기본 토크나이저인 unicode61이 공백 없이 작성되는 일본어, 한국어 등 CJK 언어의 부분 문자열 검색을 제대로 수행하지 못하는 문제를 다룹니다. 'trigram' 토크나이저를 사용하여 이를 간단히 해결하는 방법과 그 원리를 설명합니다.

핵심 포인트

  • 기본 unicode61 토크나이저는 공백 기반으로 작동하여 CJK 언어 검색에 취약함
  • trigram 토크나이저를 사용하면 3글자 단위 윈도우로 인덱싱하여 부분 검색 가능
  • 검색 재현율(Recall) 문제를 방지하기 위해 다국어 환경에서는 설정 주의 필요
  • SQL 한 줄(tokenize='trigram')로 복잡한 검색 문제를 해결할 수 있음

만약 여러분이 SQLite 위에 Claude Code 대화 기록, 메모 앱, 인덱싱된 지식 베이스 등 어떤 종류의 개인용 메모리 레이어(personal-memory layer)를 구축하고 있다면, FTS5에는 처음 접했을 때 대부분의 사람들을 당황하게 만드는 날카로운 모서리(sharp edge)가 있습니다.

기본 토크나이저(unicode61)는 대부분의 일본어 부분 문자열(substring) 쿼리를 조용히 누락시킵니다. 해결책은 단 한 줄의 SQL입니다. 하지만 이 실패 모드(failure mode)는 너무 눈에 띄지 않아서, 개인용 검색 도구를 출시하고 몇 주 동안 사용하면서도 콘텐츠의 절반을 검색할 수 없다는 사실을 전혀 깨닫지 못할 수도 있습니다.

이 포스트에서는 다음 내용을 다룹니다:

  1. 20줄의 Python 코드로 재현 가능한 실패 사례
  2. 한 줄 해결책(tokenize='trigram')과 그것이 내부적으로 실제로 수행하는 작업
  3. 약 800개의 Claude Code 대화를 위해 프로덕션 환경에서 이 인덱스를 사용하는 2계층 Git + SQLite 설계
  4. time-blocking 스타일의 쿼리를 깨뜨리는 - 문자와 관련된 별도의 FTS5 실수(footgun)
  5. 여러분의 데이터에 동일한 방식을 적용해보고 싶다면 마지막에 제공되는 무료 GitHub 샘플

20줄로 재현 가능한 실패 사례

기본 설정으로 새로운 SQLite FTS5 테이블을 생성하고 단일 다국어 문장을 삽입해 보겠습니다:

import sqlite3

conn = sqlite3.connect(":memory:")
...

출력 결과:

'time': 1 hit(s)
'blocking': 1 hit(s)
'朝の運用': 0 hit(s)
...

동일한 행, 동일한 콘텐츠입니다. 영어 쿼리는 검색되지만, 일본어 부분 문자열 쿼리는 검색되지 않습니다. 이것은 버그가 아니라 기본 토크나이저(default tokenizer)의 동작 방식이며, 기본 설정은 이에 대해 경고를 출력하지 않습니다.

이유는 다음과 같습니다: unicode61은 공백(whitespace)과 유니코드 단어 경계 속성(unicode word-break properties)을 기준으로 텍스트를 분할합니다. 영어 단어는 단어 사이에 공백이 있어 개별 토큰(token)을 복구할 수 있습니다. 일본어(및 기타 CJK 스크립트)는 단어 사이에 공백을 두지 않기 때문에, "朝の運用フロー"라는 전체 문구가 하나의 불투명한 토큰이 됩니다. 부분 문자열인 朝の運用을 검색하는 것은 FTS5에게 "이 토큰이 이 쿼리와 일치하는가?"라고 묻는 것과 같습니다. 그런데 토큰은 朝の運用フロー이므로 일치하지 않는 것입니다.

공백으로 구분되지 않는 모든 언어(Chinese, Korean, Thai 등)에서도 동일한 문제가 발생합니다. 인덱스에 해당 언어의 콘텐츠가 포함되어 있는데 기본 토크나이저 (default tokenizer)를 사용하고 있다면, 인지하지 못하는 재현율 (recall) 문제가 발생하게 됩니다.

한 줄로 해결하는 방법

CREATE VIRTUAL TABLE notes USING fts5(
    content,
    tokenize = 'trigram'
...

이 테이블을 대상으로 동일한 테스트를 다시 실행하면 다음과 같습니다:

'time': 1 hit(s)
'blocking': 1 hit(s)
'朝の運用': 1 hit(s)
...

네 개의 쿼리가 모두 일치합니다. 트리그램 (trigram) 토크나이저가 다르게 작동하는 방식은 다음과 같습니다.

단어 경계 (word boundaries)를 찾으려고 시도하는 대신, 모든 입력 문자열의 모든 겹치는 3글자 윈도우 (3-character window)를 인덱싱합니다. 일본어 부분 문자열인 "朝の運用フロー"는 다음과 같이 변환됩니다:

朝の運
の運用
運用フ
...

이제 朝の運用에 대한 검색이 일치하게 되는데, 이는 부분 문자열인 "朝の運"과 "の運用"이 모두 인덱스가 이미 알고 있는 윈도우이기 때문입니다. 트리그램은 언어에 공백이 있는지 여부를 상관하지 않습니다. 단어 수준 (word level) 미만에서 작동하기 때문입니다.

트레이드오프 (trade-off)는 인덱스 크기입니다. 모든 라인은 N - 2개의 트리그램 항목으로 인덱싱됩니다 (여기서 N은 라인의 길이). 실제 말뭉치 (corpora)에서는 원문 텍스트 크기의 약 1.5배에서 2배 정도가 됩니다. 메가바이트 단위의 콘텐츠를 가진 개인용 메모 도구라면 이는 아무것도 아닙니다. 하지만 100GB 규모의 말뭉치라면 더 신중하게 고민해야 할 것입니다.

두 가지 실무적인 참고 사항:

  • 트리그램은 최소 3글자 이상의 쿼리가 필요합니다. (1글자) 또는 朝の (2글자)와 같은 검색은 성능이 저하됩니다. 더 비용이 많이 드는 쿼리 계획 (query plan)으로 대체되기 때문입니다. 애플리케이션 계층 (app layer)에서 짧은 쿼리를 필터링하세요.
  • BM25 랭킹 (ranking)은 트리그램 토크나이징 위에서도 여전히 작동합니다. 800개의 문서에서 mochi를 검색하여 가장 관련성이 높은 상위 5개를 가져오는 방식은 변하지 않습니다.

이중 계층 설계: 신뢰할 수 있는 원천으로서의 Git, 일회용 인덱스로서의 SQLite

이 인덱스는 제가 Claude Code 대화 기록을 위해 구축한 메모리 계층의 검색 부분을 담당합니다. 몇 달간 사용하며 약 800개의 대화가 쌓인 후, 이 아키텍처는 의도적인 분리를 중심으로 안정화되었습니다:

┌────────────────────────────────────────┐
│  Git repo: sample_events/              │   ← 신뢰할 수 있는 단일 원천 (source of truth)
│    └── 2026-06-09/                     │      (내구성이 있고, 사람이 읽을 수 있는,
...

각 대화(또는 하나의 대화 내에서 이루어진 각각의 의미 있는 결정)는 디스크 상의 JSON 이벤트가 됩니다:

{
  "event_id": "0bf61ebd-c835-4a45-a147-675369258d61",
  "timestamp": "2026-05-09T12:00:00Z",
...
}

저장 경로는 다음 세 가지 작업을 순서대로 수행합니다:

def save_and_index(event: dict) -> tuple[Path, bool]:
    path = write_event(event)                       # 1. JSON을 디스크에 기록
    rel  = str(path.relative_to(MEMORY_DIR))
...

분리한 이유는 다음과 같습니다: SQLite 인덱스(index)는 쿼리 속도 측면에서는 훌륭하지만, 어떤 데이터의 유일한 복사본으로서 신뢰하기에는 취약합니다. 잘못된 스키마 마이그레이션(schema migration), FTS5 손상, 혹은 rm _data/*와 같은 오타 — 이 중 어떤 상황이 발생하더라도 패닉에 빠지는 것이 아니라 몇 초 내에 복구할 수 있어야 합니다. 이 설계에서는 인덱스를 삭제하고 Git 저장소(repo)로부터 다시 빌드하는 것이 단 한 번의 명령어로 가능합니다. Git에 있는 Markdown / JSON이 계약(contract)이며, 데이터베이스는 단지 속도가 빠른 캐시(cache)일 뿐입니다.

또한 이 방식은 SQLite 파일 자체를 완전히 일회용으로 만듭니다. .gitignore에 추가해도 위험이 없으며, 여러 기기 간에 동기화하는 것에 대한 걱정도 없습니다. 어떤 기기에서든 인덱스를 먼저 빌드하는 쪽이 승리하며, 새로운 기기에서 재빌드하는 데는 몇 초밖에 걸리지 않습니다.

FTS5의 또 다른 실수 유발 요인: - 연산자

사용자가 하이픈(hyphen)이 포함된 용어를 검색하는 순간 맞닥뜨리게 될 FTS5의 두 번째 보이지 않는 실패 요인이 있습니다. 하이픈 -은 NOT 연산자로 파싱됩니다. 따라서 누군가 다음과 같이 실행하면:

conn.execute("SELECT * FROM notes WHERE content MATCH ?", ("time-blocking",))

FTS5는 이를 time NOT blocking으로 인식하여 다음과 같은 오류를 내며 충돌합니다:

sqlite3.OperationalError: no such column: blocking

해결 방법은 사용자가 제공한 쿼리를 FTS5에 전달하기 전에 큰따옴표(")로 감싸는 것입니다. 이렇게 하면 파서(parser)가 전체를 하나의 리터럴 구절(literal phrase)로 처리하게 됩니다.

def to_fts_phrase(query: str) -> str:
    # FTS5 문법에 따라 내부의 큰따옴표(")를 두 번 써서 이스케이프(escape)합니다.
    escaped = query.replace('"', '""')
...

특수 문자가 포함되어 있는지 여부와 관계없이 모든 사용자 입력 쿼리에 이를 적용하십시오. 이것이 유일하게 안전한 기본값입니다. 구절(phrase)로 감싸진 쿼리는 트리그램 토크나이저(trigram tokenizer) 환경에서도 여전히 부분 문자열(substring)을 올바르게 매칭하므로, 단점은 없습니다.

사용자가 FTS5 쿼리에 자유 형식의 문자열을 입력할 수 있는 기능을 구축하고 있다면, 이 기능은 첫 번째 사용자가 나타나기 전에 반드시 적용되어야 합니다.

제가 실제로 이것을 사용하는 용도

동기에 대해 솔직히 말씀드리자면: 저는 전략적 결정, 절반만 작성된 초안, 전날의 디버그 로그 등 대부분의 프로젝트 노트를 Claude.ai 대화 내에 보관합니다. 약 800개의 대화가 쌓인 후 내장된 사이드바 검색이 제대로 작동하지 않기 시작했는데, 그 실패 원인이 바로 위에서 언급한 트리그램 토크나이저(trigram-tokenizer) 문제였습니다. 제 노트는 일부 일본어로 작성되어 있어서(저는 일본인 개발자라 어쩔 수 없습니다), 제가 직접 수행하는 부분 문자열(substring) 쿼리의 절반이 아무것도 반환하지 않았습니다.

문제를 해결하며 만들어낸 설정은 다음과 같습니다:

  • Claude Code를 위한 SessionStart 훅(hook): SQLite에서 최근 7일간의 이벤트를 가져와 claude -p에 전달하여 한 단락의 요약을 만들고, 이를 모든 새 세션의 상단에 시스템 메시지(system message)로 주입합니다. 그 결과, Claude는 제가 어디서 멈췄는지 이미 알고 있는 상태로 열립니다.
  • 세션이 종료될 때 결정을 자동으로 기록하는 Stop 훅(hook).
  • CLI: memory_ask "朝の運用"를 실행하면 노트북에서 약 800개의 전체 대화를 대상으로 50ms 미만 안에 순위가 매겨진 검색 결과(hits)를 반환합니다. 이 쿼리가 작동한다는 사실 자체가 트리그램(trigram) 전환의 핵심입니다.

가장 놀라운 이점은 Claude가 제가 잊고 있었던 추론 내용을 언급해 주는 것이었습니다: "3주 전에 이 접근 방식을 고려했지만 X라는 이유로 거절했습니다." 기록할 당시에는 당연해 보였던 이유들이 3개월 정도 지나면 희미해지는데, 이러한 회상 기능은 시스템 전체의 가치를 충분히 보상하고도 남습니다.

여러분의 컴퓨터에서 직접 시도해 보세요

세 가지 가상의 이벤트에 대해 엔드 투 엔드 (end-to-end)로 실행되는 인덱서의 오픈 소스 샘플이 있습니다:

https://github.com/tititi533551-create/mochi-memory-sample-en

git clone https://github.com/tititi533551-create/mochi-memory-sample-en.git
cd mochi-memory-sample-en
python3 check_mochi.py

이 샘플은 저장소를 클론(clone)하고, SQLite FTS5 인덱스를 빌드하며, 세 가지 샘플 이벤트에 대해 다섯 개의 영어/일본어 혼합 쿼리를 실행하고, 로컬 환경에서 트리그램 (trigram) 인덱스가 작동하는 모습을 출력합니다. 외부 의존성은 없으며, 오직 Python 표준 라이브러리와 FTS5가 컴파일된 sqlite3 모듈만 필요합니다 (이는 macOS, Homebrew Python, 공식 Windows 설치 프로그램 및 대부분의 Linux 배포판의 기본 설정입니다).

이 샘플은 MIT 라이선스를 따릅니다. 사용하거나, 포크(fork)하거나, 여러분이 구축 중인 어떤 검색 레이어(search layer)에도 트리그램 패턴을 적용해 보세요.

여전히 해결하고 싶은 과제들

읽으시는 분들 중에 의견이 있으실 수도 있으니, 이 설계가 아직 해결하지 못한 몇 가지 사항을 적어둡니다:

  • 시맨틱 회상 (Semantic recall) 추가. 트리그램은 부분 문자열 검색 (substring search)에는 탁월하지만, "가격 전략을 논의했던 대화를 찾아줘"와 같은 쿼리는 형태가 다르며 벡터 임베딩 (vector embeddings)을 필요로 합니다. 두 인덱스는 동일한 JSON 이벤트 상에서 공존할 수 있지만, 저는 아직 두 번째 레이어를 구축하지 않았습니다.
  • 프로젝트 간 메모리 (Cross-project memory). 현재는 각 프로젝트가 자체적인 저장소 (repo)를 가집니다. "내가 이전에 X에 대해 결정한 적이 있었나?"와 같은 질문에 유용하도록 모든 프로젝트를 가로질러 살펴보는 연합 쿼리 (federated query)에 대한 논거가 있지만, 적절한 설계가 명확하지 않습니다. 프로젝트별 컨텍스트 격리 (context isolation) 또한 가치가 있습니다.
  • 이벤트 세분성 휴리스틱 (Event-granularity heuristics). 기록하기에 적절한 단위는 무엇일까요? 저는 경험적으로 "결정 사항, 출시한 것들, 배운 점, 막혔던 부분"으로 결론을 내렸지만, 누군가 이를 공식화했는지 궁금합니다.

만약 여러분이 이 중 하나라도 해결했거나, 혹은 동일한 문제의 다른 버전을 겪었다면 어떻게 해결했는지 진심으로 듣고 싶습니다. 아래에 댓글을 남겨주세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0