個人で作っているノートアプリ(hanamask)に、LLMをアプリの中に丸ごと入れてみました。インストールした時点でAIが同梱されていて、ネットにも繋がず、APIキーも要らずに動きます。
この記事は、そのためにどのモデルを選んだかと、選んでから動くまでに引っかかった落とし穴の記録です。
なぜ今、ローカルLLMを組み込むのか
きっかけは、小さいモデルが急に賢くなってきたことです。
少し前まで、手元のPCで動くサイズのモデルは「動くけれど実用には遠い」というものが大半でした。ところが Qwen をはじめとする最近の小型モデルは、数百MB〜数GBのサイズで、実際の作業に使える水準に届いてきています。この流れが続くなら、近いうちに「チャットもローカルで完結する」のが普通になると思っています。
そうなったとき、アプリ側に土台が無いと乗り遅れます。ローカルLLMをアプリに積むには、モデルを選ぶだけでは足りず、次のような仕組みが要ります。
- 推論エンジン(LLMを動かすプログラム)をアプリに同梱して配る仕組み
- モデルファイルを安全に配布し、改ざんされていないか確かめる仕組み
- 「同梱したモデルが本当に正しく動いているか」を検証する仕組み
- ライセンス表示や、モデルが壊れたときにアプリ全体を巻き込まない設計
**これらは、どのモデルを積むかとは独立に必要になります。**だったら、いま確実に価値の出る用途で一度作っておこう、というのが動機です。
その「確実に価値の出る用途」として選んだのが意味検索でした。文章の意味を数字の並び(ベクトル)に変換する埋め込みモデルは、チャット用のモデルよりずっと小さく(今回は約73MiB)、CPUだけで十分速く動きます。土台を作る最初の一歩としてちょうどよいサイズです。
つまりこの記事は「意味検索を作った話」であると同時に、**「アプリにLLMを積むための足場を組んだ話」**でもあります。
そもそも何をしたいのか
hanamaskは、AIエージェント(Claude Code等)が読み書きするノートアプリです。これまでの検索はキーワードが一致しないと見つからないものでした。
たとえば「WSLからMCPにつなぐときの詰まりどころ」と書いたノートは、「Windows側への接続でハマった話」で検索しても出てきません。エージェントが作業前に過去の経緯を引こうとしても、これでは取りこぼします。
そこで意味検索を足しました。仕組みはこうです。
- ノートの本文を、モデルに通して**数字の並び(ベクトル)**に変換しておく
- 検索するときは、検索文も同じように数字の並びに変換する
- 数字として近いものを探す
「近い意味の文は、近い数字になる」ように学習されたモデルを使うので、単語が違っても意味が近ければ見つかります。
選定条件
外部のサービスを呼ぶのではなく、アプリに同梱して配る方針にしました。理由は3つです。
- 利用者に別のソフトの導入を求めたくない(インストーラーだけで完結する)
- ノートの中身を外に送りたくない
- インストールした直後から動いてほしい
同梱すると決めた時点で、条件は自動的に絞られます。
| 条件 | なぜ必要か |
|---|---|
| 再配布できるライセンス(Apache-2.0 / MIT 等) | インストーラーに入れて配るため。「非商用のみ」「独自ライセンス」は使えない |
| 日本語で品質を測る | ノートは日本語がメイン。「多言語対応」という看板ではなく、日本語のベンチマーク(JMTEB)の数字で見る |
| 小さい(量子化後50〜300MB) | インストーラーが太る。当時118MBだったので、倍以上にはしたくない |
| llama.cppで動く | アプリに積む推論エンジンがllama.cppのため。対応していないモデル形式は動かせない |
量子化とは、モデル内部の数値の精度を落としてファイルを小さくする処理です。今回使った「Q8_0」は8ビットに落とす方式で、サイズが1/4程度になる代わりに精度はほとんど落ちません。
GGUFは、llama.cppが読めるモデルファイルの形式です。多くのモデルは別の形式で配布されているので、GGUFに変換して使います。
候補比較(2026年8月時点)
| モデル | パラメータ数 | ライセンス | JMTEB平均 | サイズ(Q8_0) | 判定 |
|---|---|---|---|---|---|
| cl-nagoya/ruri-v3-70m | 70M | Apache-2.0 | 73.95 | 約77MB(自前変換) | 採用 |
| cl-nagoya/ruri-v3-30m | 37M | Apache-2.0 | 72.95 | 約41MB | もっと小さくしたい場合 |
| intfloat/multilingual-e5-small | 118M | MIT | 67.38 | 約132MB | 次点(変換の手間ゼロ) |
| BAAI/bge-m3 | 568M | MIT | 72.46 | 約635MB | サイズ超過 |
| pkshatech/GLuCoSE-base-ja-v2 | 133M | Apache-2.0 | 71.11 | — | llama.cpp未対応 |
| sbintuitions/sarashina-embedding-v1-1b | 1.2B | 非商用限定 | 74.87 | — | ライセンスで除外 |
| google/embeddinggemma-300m | 303M | Gemma Terms | 70.59 | — | 独自ライセンスで除外 |
| Qwen/Qwen3-Embedding-0.6B | 596M | Apache-2.0 | Retrieval 72.81 | 約639MB | サイズ超過。日本語ではRuriに劣後 |
JMTEBの値はsbintuitionsの公式リーダーボード(2025-10スナップショット)から。
**決め手は「Ruri v3 70mは、8倍大きいbge-m3より日本語の成績が良い」**という点でした。日本語に特化して学習されたモデルは、汎用の大きいモデルを小さいサイズで上回ることがあります。
性能表だけ見ていたら選んでいた候補が、2つの条件で落ちました。
- ライセンス: sarashinaは品質最上位ですが非商用限定。EmbeddingGemmaはGemma Termsで、配った先にも条件が伝播します
- llama.cpp対応: GLuCoSEやarcticは、変換スクリプトにそのモデル形式が登録されていないので変換すらできません
落とし穴1: 変換したら、濁点が消えた
ここからが本題です。「GGUFが公開されている=そのまま使える」ではありませんでした。
何が起きたか
変換したモデルで試したところ、「ばびぶべぼ」「がぎぐげご」が消えていました。「が」が「か」になるのではなく、文字ごと無かったことになるのです。
なぜ起きたか
モデルは文章をそのまま扱えないので、トークナイザという部品で文章を細かい単位(トークン)に切ってから処理します。この切り方にはいくつか方式があり、Ruriは Unigram という方式を使っています。
一方、llama.cppの変換スクリプトは、Ruriが派生元にしている ModernBERT というモデルについて英語向けの別方式(BPE)を前提に書かれていました。Unigramの語彙を正しく書き出す経路が、その時点では用意されていなかったのです。
その結果、変換後のファイルには「このモデルはWordPieceという別方式を使う」と記録されてしまいます。llama.cppのWordPiece処理は、既定でアクセント記号を取り除く正規化をかけます。日本語の濁点・半濁点は内部的にアクセント記号と同じ扱いで分解されうるので、「が」→「か」+濁点 に割れたあと、濁点だけが捨てられて空になる——これが原因でした。
これは変換スクリプトが想定していなかった組み合わせで起きる話で、モデルを作った方やGGUFを公開してくださっている方に落ち度があるわけではありません。自分で素直に変換しても同じ結果になります。
どう直したか
変換スクリプトに、「Unigram形式の語彙ならUnigramとして書き出す」分岐を1箇所足しました。
def set_vocab(self):
self.gguf_writer.add_add_bos_token(True)
self.gguf_writer.add_add_eos_token(True)
self.gguf_writer.add_add_sep_token(True)
if (self.dir_model / "tokenizer.model").is_file():
# ModernBERT-Ja 派生(cl-nagoya/ruri-v3-*)は SentencePiece Unigram
return self._modernbert_unigram_set_vocab()
self._set_vocab_gpt2()
中身は「語彙とスコアを読んで、t5(llama.cpp内部でUnigramを指す名前)として書く」だけです。ここで別の関数を使うと似て非なる方式になるので、そこだけ注意が要りました。
python convert_hf_to_gguf.py ./ruri-v3-70m --outfile ruri-v3-70m-q8_0.gguf --outtype q8_0
落とし穴2: エラーは出ないのに、精度だけ落ちる設定が2つ
自前で変換したファイルでも、最初の計測では一致度が0.91〜0.97止まりでした。原因は推論エンジン側の設定が2つでした(node-llama-cpp v3.20.0時点の挙動です)。
1. 文頭の目印が付かない
多くの埋め込みモデルは「文頭の目印(BOS)+本文+文末の目印(EOS)」という形で学習されています。ところが今回の語彙方式では、文字列を渡すと文頭の目印が付きませんでした。学習時と形が違うので、出てくる数字がずれます(一致度 0.955 → 0.895)。
対処は、文字列ではなく自分で目印を付けたトークンの配列を渡すことです。
2. 長い文が途中で分割される
処理を一度に流せる量に既定の上限(512トークン)があり、それを超える入力は分割して処理されます。ところが埋め込みは「文章全体を1回で見て、平均を取る」前提の処理なので、分割されると平均が文章の一部にしか効きません。522トークンの入力で一致度0.773まで落ちました。
対処は、この上限を扱える最大長と同じ値に揃えることです。
const ctx = await model.createEmbeddingContext({ contextSize: 2048, batchSize: 2048 });
const tokens = [model.tokens.bos, ...model.tokenize(text), model.tokens.eos];
const { vector } = await ctx.getEmbeddingFor(tokens);
この2点を直して、10文で一致度 0.9998以上、1554トークンの長文でも0.9999で一致しました。濁点・半濁点もバイト単位で完全に一致しています。
**この2つも、落とし穴1と同じく「エラーが出ない」種類の問題です。**組み込んでからでは気づけないので、元のモデルと出力を突き合わせる検証を必ず通す必要がありました。エンジンを更新したときのために、その検証スクリプトはリポジトリに残してあります。
組み込みで決めたこと
- 推論エンジン: node-llama-cpp(MIT)をアプリに同梱。CPU版だけを入れます(+約10MB)。GPU版は利用者側に別途ランタイムが必要になるうえ、この程度のサイズのモデルはCPUで十分速いためです
- モデルの置き場所: アプリのリソースとして同梱します(アプリの圧縮領域の中には置けないため、外に出す指定が要ります)
- 設定を1つのファイルに集約: 次元数・扱える最大長・ライセンス・検索文と本文それぞれに付ける接頭辞(Ruriは「検索クエリ: 」「検索文書: 」が必須)をJSONに書き、モデルの差し替えはこのファイルとモデル本体の交換だけで済む形にしました。これが「土台」の中心です
- ランキング: ベクトルを正規化してSQLiteに保存し、内積で並べます。個人規模なので総当たりで十分速く、専用の拡張は入れていません
- 絶対値で足切りしない: 日本語の埋め込みは、無関係な文どうしでも0.6〜0.8くらいの類似度が出ます。「0.8以上なら関連あり」といった閾値は使わず、順位だけを見せるようにしました
- 速度: CPUでモデル読み込み約3.6秒(初回のみ)、1文あたり約50ms
ライセンスと安全性で気にしたこと
ライセンス
- RuriはApache-2.0。GGUFへの変換と量子化は「改変」にあたるので、①ライセンス全文の同梱 ②改変した旨(形式変換と8bit量子化のみ)の明記 ③帰属表示 を行いました
- 自前変換したモデルはアプリのリリース資産として配布し、ダウンロード時にハッシュ値を検証します(一致しなければ破棄)
安全性
- モデルファイルはネイティブコードが解析する形式なので、細工されたファイルによる脆弱性のリスクがあります。ハッシュ検証を通ったファイル以外は読みません
- 推論は完全にローカルで、モデル取得(ビルド時)以外に通信が発生しないことをテストで固定しました
- モデルの読み込みに失敗しても、意味検索が使えないだけでアプリ本体は通常どおり動きます。AIの部分がアプリ全体を道連れにしない構造にしています
まとめ
- **小型モデルが実用域に入ってきたので、アプリに積む土台を先に作った。**その最初の題材として、小さくて効果の分かりやすい埋め込みモデルを選んだ
- 日本語向けの小型モデルは選択肢が増えていて、Ruri v3 70mは約77MBで8倍大きいモデルを上回る。ライセンスも組み込みに向く
- **「公開されているGGUF=そのまま使える」ではない。**トークナイザの方式が合っているか、元のモデルと出力が一致するかを、実測してから採用する
- 推論エンジン側にも「エラーにならず精度だけ下がる」設定がある。元モデルとの一致検証を回帰テストとして残すのが一番の保険だった
チャット用のローカルモデルを積むのは次の段階ですが、**配布・検証・差し替えの仕組みはここで揃いました。**モデルが賢くなるのを待つ側の準備は、これでできたと思っています。