キーワード検索からベクトル検索へ:SQLiteを使った実践ガイド
SQLiteのFTS5とsqlite-vecを使ったキーワード検索とベクトル検索の実践的な比較。それぞれのアプローチが優れている場面を実例で紹介します。
SQLiteにはFTS5という高速で実績のある全文検索エンジンが搭載されており、ほとんどのアプリにとっては十分な機能です。では、なぜわざわざベクトル検索を追加する必要があるのでしょうか?
キーワード検索には根本的な制限があるからです。文字通り入力した内容しか見つけることができません。メモに「deployment pipeline」と書いてあっても、「CI/CD workflow」で検索するとFTS5は何も返しません。ベクトル検索はこれらのフレーズがほぼ同じ意味であることを理解します。
この記事では、自分が作ったメモアプリの実例を使って両方のアプローチを比較し、sqlite-vecを使って既存のSQLiteデータベースにベクトル検索を追加する方法を具体的に紹介します。
SQLiteのFTS5は、テキストに対して転置インデックスを構築する仮想テーブルです:
CREATE VIRTUAL TABLE notes_fts USING fts5(
content
);
-- Search
SELECT * FROM notes_fts
WHERE notes_fts MATCH 'meeting notes'; FTS5はプレフィックスクエリ、フレーズマッチング、ブール演算子(AND、OR、NOT)、そしてBM25によるランキングをサポートしています。多くのアプリにとっては、これだけで十分です。
FTS5が優れている場面:
FTS5が苦手な場面:
sqlite-vecはAlex Garcia氏によるSQLite拡張機能で、仮想テーブルを通じてベクトル演算を追加します。単一のCファイルで、外部依存関係もなく、SQLiteが動作するあらゆる環境で使えます。
まず、ベクトル用の仮想テーブルを作成します:
CREATE VIRTUAL TABLE vec_note_chunks
USING vec0(
sentence_embedding float[384]
); 384は埋め込みの次元数で、使用するモデルによって決まります。私はAllMiniLM-L12-V2を使用しましたが、これは384次元のベクトルを出力します。OpenAIのtext-embedding-3-smallを使う場合は1536次元になります。
ドキュメントごとに2つのものが必要です:生テキスト(表示用)とその埋め込み(検索用)。
-- Regular table for text and metadata
CREATE TABLE note_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sentence TEXT NOT NULL,
sentence_embedding BLOB,
note_id INTEGER,
FOREIGN KEY(note_id)
REFERENCES notes(id) ON DELETE CASCADE
);
-- Vector index
CREATE VIRTUAL TABLE vec_note_chunks
USING vec0(
sentence_embedding float[384]
); パターンは次の通りです:埋め込みを通常のテーブル(後で取得するためのblobとして)と仮想テーブル(インデックス検索用)の両方に保存します。rowidがそれらを紐付けます:
WITH related_chunks AS (
SELECT rowid, sentence_embedding
FROM note_chunks
WHERE note_id = ?1
)
INSERT INTO vec_note_chunks (
rowid, sentence_embedding
)
SELECT rowid, sentence_embedding
FROM related_chunks;コアとなる検索クエリは以下の通りです:
WITH matches AS (
SELECT rowid, distance
FROM vec_note_chunks
WHERE sentence_embedding MATCH ?1
AND distance > 0.8
ORDER BY distance
LIMIT 10
)
SELECT DISTINCT
notes.id,
notes.content,
notes.created_at
FROM matches
JOIN note_chunks
ON note_chunks.id = matches.rowid
LEFT JOIN notes
ON notes.id = note_chunks.note_id;これを分解して見てみましょう:
MATCH ?1 — クエリの埋め込み(floatのJSON配列として)distance > 0.8 — コサイン類似度の閾値。高いほど類似度が高い。0.8は「明確に関連する」コンテンツに適したカットオフだと分かりました私のメモアプリからの実例を紹介します:
| Query | FTS5 Result | Vector Result | Winner |
|---|---|---|---|
| "meeting notes" | Exact matches only | Also finds "standup recap", "team sync" | Vector |
| "error code 502" | Finds the exact error | Finds vaguely related server errors | FTS5 |
| "how to deploy" | Notes containing "deploy" | Also finds "CI/CD", "release process" | Vector |
| "React useState" | Exact match on API name | Fuzzy matches on state management | FTS5 |
| "feeling stuck" | Nothing (no notes use that phrase) | Finds notes about blockers, debugging frustration | Vector |
パターン:FTS5は精度が重要な場面で勝つ(正確な用語、コード、識別子)。ベクトル検索は再現率が重要な場面で勝つ(探索的検索、曖昧な概念、再発見)。
ベクトル検索で最も難しいのはインフラではなく、適切な類似度閾値の選択です。
閾値を高く設定しすぎると(例:0.95)、ほぼ完全一致のみが返され、目的を損ないます。低く設定しすぎると(例:0.5)、ノイズの多い無関係な結果が返されます。
自分のメモで手動テストした結果、0.8に落ち着きました。観察した結果は以下の通りです:
これらの数値はAllMiniLM-L12-V2に固有のものです。異なるモデルでは距離の分布が異なります。必ず自分のデータでキャリブレーションしてください。
便利なパターンの一つ:各メモのセントロイド埋め込み(全チャンク埋め込みの平均)を計算する方法です。これにより、メモ全体をおおまかに表す単一のベクトルが得られます:
let centroid: Vec<f32> = sum_vector
.iter()
.map(|&sum| sum / num_vectors as f32)
.collect();セントロイドを使えば、すべてのチャンクペアを比較することなく、メモ間の類似度(「このメモに似たメモを表示して」)を計算できます。近似ではありますが、高速で実用的です。
実際には、両方を使いたいでしょう。ハイブリッドアプローチ:
これにより、ユーザーが正確な用語を入力したときは精度が、探索的な検索をしているときは再現率が得られます。多くの本番検索システム(ElasticsearchのkNN、PostgreSQLのpgvector)もこのように動作しています。
私のアプリではハイブリッド検索は実装しませんでしたが、もし最初からやり直すなら実装するでしょう。2つのアプローチは競合するものではなく、補完的なものです。
約500件のメモ(約2,000チャンク)のデータセットで、M1 MacBookを使用した場合:
個人のメモアプリにとっては、どちらも事実上瞬時です。より大規模なデータセット(10万件以上のドキュメント)の場合は、HNSWインデックスや専用のベクトルデータベースを検討する必要があるでしょう。
既存のSQLiteアプリにベクトル検索を追加したい場合:
私のアプリのベクトル検索レイヤー全体は、約100行のSQLと150行のRustで構成されています。SQLite + sqlite-vecは、専用のベクトルデータベースが提供する機能の80%を、運用オーバーヘッドゼロで実現します。
SQLiteは静かにAI搭載アプリケーションの正当なプラットフォームになりつつあります。FTS5、sqlite-vec、そして今後予定されている公式ベクトル検索サポートにより、SQLiteのエコシステムを離れることなく、驚くほど洗練された検索を構築できます。