RustとSQLiteでデスクトップアプリにセマンティック検索を構築する
rust-bert、sqlite-vec、Tauriを使って、ノートアプリにオフラインのML搭載セマンティック検索を構築した方法のステップバイステップガイド。
ほとんどのノートアプリはキーワード検索を提供しています。「会議メモ」と入力すると、まさにその単語を含むノートが返されます。しかし、代わりに「チームのスタンドアップまとめ」と書いていたらどうでしょう?キーワード検索では見つかりません。セマンティック検索なら見つかります。
この記事では、Tauriで構築されたデスクトップノートアプリInsight Notesに完全オフラインのセマンティック検索を構築した方法を説明します。APIコールなし、クラウドなし — すべてお使いのマシン上で動作します。
このシステムには4つの重要な要素があります:
それぞれについて見ていきましょう。
ノート全体を1つのベクトルとしてエンベッドすることはできません。長いドキュメントは単一のエンベディングに圧縮するとニュアンスが失われます。そこで、langchain-rustのMarkdownSplitterを使って各ノートをチャンクに分割しています:
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トークンのチャンクサイズが良いバランスでした — 正確なマッチングには十分小さく、コンテキストを保持するのに十分な大きさです。
各チャンクは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(),
)
}ここが面白いところです。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 (),
)),
);
}ユーザーが検索すると、クエリテキストは同じ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コストなし、プライバシーの懸念もありません。
開発中に学んだいくつかのこと:
ローカルファーストのアプリを構築していて、意味を理解する検索が必要な場合、このスタック — rust-bert + sqlite-vec + Tauri — は驚くほど優秀です。エンベディングの知性とSQLiteのシンプルさを両立できます。