RustとSQLiteでデスクトップアプリにセマンティック検索を構築する

2026-02-28
--

rust-bert、sqlite-vec、Tauriを使って、ノートアプリにオフラインのML搭載セマンティック検索を構築した方法のステップバイステップガイド。


ほとんどのノートアプリはキーワード検索を提供しています。「会議メモ」と入力すると、まさにその単語を含むノートが返されます。しかし、代わりに「チームのスタンドアップまとめ」と書いていたらどうでしょう?キーワード検索では見つかりません。セマンティック検索なら見つかります。

この記事では、Tauriで構築されたデスクトップノートアプリInsight Notesに完全オフラインのセマンティック検索を構築した方法を説明します。APIコールなし、クラウドなし — すべてお使いのマシン上で動作します。

アーキテクチャの概要

このシステムには4つの重要な要素があります:

それぞれについて見ていきましょう。

ステップ1:Markdownのチャンキング

ノート全体を1つのベクトルとしてエンベッドすることはできません。長いドキュメントは単一のエンベディングに圧縮するとニュアンスが失われます。そこで、langchain-rustMarkdownSplitterを使って各ノートをチャンクに分割しています:

async fn split_markdown_content(content: &str) -> Vec<String> {
    let options = SplitterOptions {
        chunk_size: 256,
        ..Default::default()
    };

    MarkdownSplitter::new(options)
        .split_text(content)
        .await
        .unwrap()
}

MarkdownSplitterはMarkdownに対応しているため、文の途中で分割するのではなく、見出しの境界やコードブロックを尊重します。256トークンのチャンクサイズが良いバランスでした — 正確なマッチングには十分小さく、コンテキストを保持するのに十分な大きさです。

ステップ2:エンベディングの計算

各チャンクはAllMiniLM-L12-V2モデルをrust-bert経由で使って384次元のベクトルに変換されます:

async fn create_sentence_embedding(
    text: &str,
    sentence_encoder: &SentenceEncoder,
) -> Vec<f32> {
    let sentences = [text.to_string()];
    let output = sentence_encoder
        .encode(sentences.to_vec())
        .await
        .unwrap();
    output[0].clone()
}

各ノートのセントロイドエンベディングも計算しています — すべてのチャンクエンベディングの平均です。これは、個々のチャンクをすべて比較せずに、あるノートに類似したノートを見つけるのに便利です:

fn compute_centroid_from_note_content_chunks(
    chunks: &Vec<NoteChunkToInsert>,
) -> Option<Vec<f32>> {
    let num_vectors = chunks.len();
    let dimension = chunks[0]
        .sentence_embedding_vector
        .len();
    let mut sum_vector = vec![0.0; dimension];

    for chunk in chunks {
        for i in 0..dimension {
            sum_vector[i] += chunk
                .sentence_embedding_vector[i];
        }
    }

    Some(
        sum_vector
            .iter()
            .map(|&sum| sum / num_vectors as f32)
            .collect(),
    )
}

ステップ3:SQLiteにベクトルを保存する

ここが面白いところです。PineconeやQdrantのような専用のベクトルデータベースに頼る代わりに、sqlite-vecを使いました — 仮想テーブルを通じてベクトル検索機能を追加するSQLite拡張です。

スキーマ:

CREATE VIRTUAL TABLE vec_note_chunks USING vec0(
    sentence_embedding float[384]
);

これだけです。1行でベクトルインデックスが得られます。エンベディングは通常のノートデータと一緒に挿入されます:

WITH related_note_chunks AS (
    SELECT rowid, sentence_embedding
    FROM note_chunks
    WHERE note_chunks.note_id = ?1
)
INSERT INTO vec_note_chunks (
    rowid, sentence_embedding
)
SELECT rowid, sentence_embedding
FROM related_note_chunks;

sqlite-vecは起動時に自動拡張として読み込まれます:

unsafe {
    libsqlite3_sys::sqlite3_auto_extension(
        Some(std::mem::transmute(
            sqlite3_vec_init as *const (),
        )),
    );
}

ステップ4:ベクトル類似度でクエリする

ユーザーが検索すると、クエリテキストは同じ384次元空間にエンベッドされ、保存されたベクトルと照合されます:

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,
    notes.updated_at
FROM matches
JOIN note_chunks
    ON note_chunks.id = matches.rowid
LEFT JOIN notes
    ON notes.id = note_chunks.note_id

MATCHキーワードがベクトル類似検索をトリガーします。distance > 0.8の閾値で高い信頼度のマッチ(コサイン類似度)をフィルタリングします。結果は関連度順にランク付けされて返されます。

ここでのCTEパターンは重要です — sqlite-vecにまず高コストなベクトル検索を実行させ、その後結合して完全なノートデータを取得します。結合後にフィルタリングするよりもはるかに高速です。

結果

この仕組みにより、「プロジェクトの締切」で検索すると、クエリに正確な単語が含まれていなくても「タイムラインのプレッシャー」や「納品日」に関するノートが表示されます。Ctrl+Fと比較すると、まるで魔法のような検索体験です。

パイプライン全体が最新のラップトップで100ミリ秒以内に実行されます。ネットワーク遅延なし、APIコストなし、プライバシーの懸念もありません。

次回変えたいこと

開発中に学んだいくつかのこと:

  • チャンクのオーバーラップは重要。オーバーラップするチャンクを実装しなかったため、チャンク境界のコンテキストが失われる可能性があります。20〜30%のオーバーラップを追加すると再現率が向上するでしょう。
  • ハイブリッド検索の方がよい。ベクトル検索と従来の全文検索(SQLite FTS5)を組み合わせると、ユーザーが実際に完全一致のキーワードマッチを求めているケースにも対応できます。
  • リランキングが有効。上位10件のベクトルマッチをクロスエンコーダーでリランキングすると精度が向上しますが、レイテンシが増加します。

  • ローカルファーストのアプリを構築していて、意味を理解する検索が必要な場合、このスタック — rust-bert + sqlite-vec + Tauri — は驚くほど優秀です。エンベディングの知性とSQLiteのシンプルさを両立できます。