キーワード検索からベクトル検索へ:SQLiteを使った実践ガイド

2026-02-28
--

SQLiteのFTS5とsqlite-vecを使ったキーワード検索とベクトル検索の実践的な比較。それぞれのアプローチが優れている場面を実例で紹介します。


SQLiteにはFTS5という高速で実績のある全文検索エンジンが搭載されており、ほとんどのアプリにとっては十分な機能です。では、なぜわざわざベクトル検索を追加する必要があるのでしょうか?

キーワード検索には根本的な制限があるからです。文字通り入力した内容しか見つけることができません。メモに「deployment pipeline」と書いてあっても、「CI/CD workflow」で検索するとFTS5は何も返しません。ベクトル検索はこれらのフレーズがほぼ同じ意味であることを理解します。

この記事では、自分が作ったメモアプリの実例を使って両方のアプローチを比較し、sqlite-vecを使って既存のSQLiteデータベースにベクトル検索を追加する方法を具体的に紹介します。

FTS5によるキーワード検索:ベースライン

SQLiteのFTS5は、テキストに対して転置インデックスを構築する仮想テーブルです:

CREATE VIRTUAL TABLE notes_fts USING fts5(
    content
);

-- Search
SELECT * FROM notes_fts
WHERE notes_fts MATCH 'meeting notes';

FTS5はプレフィックスクエリ、フレーズマッチング、ブール演算子(ANDORNOT)、そしてBM25によるランキングをサポートしています。多くのアプリにとっては、これだけで十分です。

FTS5が優れている場面:

  • 完全一致フレーズ検索(「error code 404」)
  • 既知アイテムの検索(正確な単語を覚えている場合)
  • 構造化クエリ(「python AND async NOT javascript」)
  • 外部依存関係ゼロ
  • 数万件のドキュメントに対してサブミリ秒のクエリ
  • FTS5が苦手な場面:

  • 同義語:「car」は「automobile」にマッチしない
  • 言い換え:「how to fix a bug」は「debugging techniques」にマッチしない
  • 概念的な検索:「productivity tips」は「getting more done」にマッチしない
  • タイプミスの許容はプレフィックスマッチングに限定される
  • sqlite-vecによるベクトル検索:アップグレード

    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は「明確に関連する」コンテンツに適したカットオフだと分かりました
  • CTEパターンが重要です:まずコストの高いベクトル検索を実行し、その後メタデータをJOINします。これにより、notesテーブル全体のスキャンを避けることができます
  • 直接対決:それぞれが勝つのはいつ?

    私のメモアプリからの実例を紹介します:

    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に落ち着きました。観察した結果は以下の通りです:

  • > 0.9:非常に類似したコンテンツ、ほぼ言い換え
  • 0.8 - 0.9:明確に関連するトピック、「関連メモ」に最適
  • 0.7 - 0.8:緩く関連、発見に役立つこともある
  • < 0.7:通常ノイズ
  • これらの数値は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を使用した場合:

  • FTS5クエリ:1ms未満
  • ベクトル検索:約5〜15ms(クエリの埋め込み処理を含む)
  • 挿入処理(チャンク分割+埋め込み+インデックス作成):メモ1件あたり約50〜100ms
  • 個人のメモアプリにとっては、どちらも事実上瞬時です。より大規模なデータセット(10万件以上のドキュメント)の場合は、HNSWインデックスや専用のベクトルデータベースを検討する必要があるでしょう。

    はじめ方

    既存のSQLiteアプリにベクトル検索を追加したい場合:

    私のアプリのベクトル検索レイヤー全体は、約100行のSQLと150行のRustで構成されています。SQLite + sqlite-vecは、専用のベクトルデータベースが提供する機能の80%を、運用オーバーヘッドゼロで実現します。


    SQLiteは静かにAI搭載アプリケーションの正当なプラットフォームになりつつあります。FTS5、sqlite-vec、そして今後予定されている公式ベクトル検索サポートにより、SQLiteのエコシステムを離れることなく、驚くほど洗練された検索を構築できます。