メインコンテンツへスキップ
ローカルLLM・自分のPCでAI
ローカルLLM
RAG
Ollama
ベクトル検索
SQLite
生成AI

ローカルRAGの作り方:Ollama × sqlite-vec で「自分のPCだけ」で動く検索を組む(精度が出ない原因つき)

ローカルLLMでRAGを組むと「動くが精度が出ない」で止まりがちです。Ollamaの /api/embed と sqlite-vec で最小構成を作り、静かに精度を壊す3つの原因——非推奨エンドポイント・truncateの既定値・タスク接頭辞の付け忘れ——を公式ドキュメントで特定して潰します。

公開日
読了時間
11分
著者
友田 陽大
シェア

「ローカルLLMでRAGを作る」記事のとおりに手を動かすと、たいてい動きます。動くのですが、しばらく使うと「入れたはずの文書が引っかからない」「それらしいが微妙にずれた答えが返る」という状態に落ち着きます。そして原因が分からないまま、モデルを大きくしたりチャンクサイズをいじったりする作業が始まります。

この記事は、その「動くが精度が出ない」を先に潰すための構築ガイドです。最小構成を動かしたあと、ローカルRAGで精度を静かに壊す3つの原因を、公式ドキュメントの一次情報に当たって特定します。3つともエラーを出さずに動いてしまう種類の問題なので、知らないと原因の候補にすら上がりません。

1. 何を作るか

構成はこれだけです。

役割使うものなぜ
埋め込み生成Ollama + nomic-embed-textローカル完結。CPUでも実用になる
ベクトル保存・検索sqlite-vecサーバもDockerも不要。ファイル1つ
応答生成Ollama の任意のチャットモデル検索が正しければモデルは後から差し替えられる

外部サービスは使いません。ネットワークを切っても動く構成です。

意図的に LangChain などのフレームワークを使いません。理由は、この記事で潰したい3つの不具合がフレームワークの内側で起きるからです。抽象の下で何が送られているかを一度見ておくと、フレームワークに戻ったときにも原因を切り分けられます。

この記事の実装を、案件として承ります

オープンウェイトLLMの自社ホスティング基盤を、構築から本番運用・コスト最適化まで

2. 最小構成を動かす

2.1 埋め込みを1本取る

ollama pull nomic-embed-text

curl http://localhost:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": "search_document: 経費精算は毎月末締めで、翌月10日までに申請します。"
}'

返ってくる形はこうです。

{
  "model": "nomic-embed-text",
  "embeddings": [[0.0100, -0.0017, 0.0500, "…768個…"]]
}

embeddings配列の配列である点に注意してください。input に配列を渡せばまとめて埋め込めます。この「まとめて渡せる」が後で効きます。

2.2 保存して引く

-- 768次元。モデルを替えたらここも替える(次元が合わないと挿入時にエラーになる)
create virtual table chunks using vec0(
  chunk_embedding float[768]
);

-- 検索:質問ベクトルに近い順に5件
select rowid, distance
from chunks
where chunk_embedding match :query_vector
order by distance
limit 5;

vec0 仮想テーブルに float[768] の列を置き、match でKNNを引く。ローカルRAGの検索部分は、本質的にはこれだけです。

正直な注意: sqlite-vec は公式READMEに「pre-v1 なので破壊的変更を覚悟してほしい」と明記されています。個人の道具や社内の検証には十分ですが、顧客に納品するプロダクトに入れるなら、バージョンを固定して移行計画を持ってください。

ここまでで動きます。そして、ここからが本題です。

3. 精度を静かに壊す3つの原因

3.1 非推奨のエンドポイントを使っている

Ollama には埋め込みのエンドポイントが2つあります。公式ドキュメントには、古い方に対してはっきりと注記があります。

Note: this endpoint has been superseded by /api/embed

違いは名前だけではありません。

/api/embed(現行)/api/embeddings(superseded)
入力キーinputprompt
複数同時できる(配列を渡す)できない(1件ずつ)
戻り値キーembeddings(配列の配列)embedding(平坦な配列)

日本語の解説記事はいまも /api/embeddings を載せているものが多く、コピーすると次の2つが同時に起きます。

  1. 戻り値の形が違うので、embeddings[0] のつもりで書いたコードが undefined を掴む。これは気づけます。
  2. バッチできないので、1万チャンクなら1万回HTTPを往復する。これは「遅いだけ」に見えて気づきません。

2つ目が効きます。インデックス作成が遅いと、チャンク設計を変えて作り直す試行そのものをやらなくなり、最初に選んだチャンクサイズのまま固定されてしまう。精度の問題が「試さなくなる」という形で現れるので、原因として認識されません。

// まとめて埋め込む。件数はモデルとメモリで決まるので、まず小さく始めて上げる
const res = await fetch("http://localhost:11434/api/embed", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "nomic-embed-text",
    input: batch.map((c) => `search_document: ${c.text}`),
  }),
});
const { embeddings } = (await res.json()) as { embeddings: number[][] };

3.2 truncate の既定値が true

/api/embedtruncate パラメータについて、公式ドキュメントはこう書いています。

truncate: truncates the end of each input to fit within context length. Returns error if false and context length is exceeded. Defaults to true

つまり既定では、コンテキスト長を超えた入力は末尾を切り落として処理されます。エラーは出ません。

これがローカルRAGで起きると、こういう形になります。長めのチャンクを入れたつもりが、後半が埋め込みに反映されていない。結果として「ドキュメントの後半に書いてある内容だけ、何度聞いても絶対に出てこない」。しかもインデックス作成時に警告もエラーも出ないので、入っていないことに気づく方法がありません

対処は2段階です。

// 1. 長すぎる入力を検出したい段階では truncate を切って、エラーで気づけるようにする
body: JSON.stringify({ model, input, truncate: false })
// 2. そのうえで、チャンク分割側を直す(切るのではなく、分ける)

インデックスを作り直すのは一度きりのコストですが、黙って切られたまま運用するのは恒久的なコストです。開発中は truncate: false にして、落ちたら分割を直す。これを一度やっておくと、後から「なぜか出てこない」を疑わずに済みます。

3.3 タスク接頭辞を付けていない(最頻出)

これが、ローカルRAGで「なんとなく精度が低い」の最も多い正体です。

nomic-embed-text の公式モデルカードは、強調してこう書いています。

Important: the text prompt must include a task instruction prefix, instructing the model which task is being performed.

For example, if you are implementing a RAG application, you embed your documents as search_document: <text here> and embed your user queries as search_query: <text here>.

文書と質問で、付ける接頭辞が違います。

埋め込む対象接頭辞
インデックスに入れる文書search_document:
ユーザーの質問search_query:

このモデルは「いま文書を埋めているのか、質問を埋めているのか」を接頭辞から判断して、別のベクトル空間の使い方をします。質問と文書は文の形が違う(質問は疑問文、文書は説明文)ので、同じ扱いにすると近さが正しく出ません。接頭辞は、その非対称性をモデルに伝えるための指示です。

厄介なのは、付け忘れても普通に動くことです。ベクトルは返ってくるし、検索結果も返ってくる。ただ、並び順が少しずつ的外れになる。エラーで気づけない種類の劣化なので、モデルを大きくする方向に時間を使ってしまいます。

// 実装では、接頭辞を付ける関数を経由させて「素のテキストを渡せない」ようにする
const asDocument = (text: string) => `search_document: ${text}`;
const asQuery = (text: string) => `search_query: ${text}`;

呼び出し側で毎回書くと必ず忘れます。型で縛るか、埋め込み関数を用途別に2つ用意して、素の文字列を受け取る口を塞いでください。

モデルを替えるときは接頭辞の規約も替わります。 これは nomic 系の作法であって、すべての埋め込みモデルに共通ではありません。bge 系は質問側にだけ別の指示文を付ける流儀、接頭辞をまったく使わないモデルもあります。モデルカードを読まずに差し替えると、この節の問題が再発します。

4. 次元を削って軽くする(正しい手順)

nomic-embed-text-v1.5 は Matryoshka Representation Learning で学習されていて、ベクトルの先頭だけを使っても意味が保たれる性質があります。768次元を512や256に落とせば、保存容量も検索時間も比例して下がります。

ただし公式の手順は「切り詰めるだけ」ではありません。モデルカードのコード例はこうなっています。

embeddings = F.layer_norm(embeddings, normalized_shape=(embeddings.shape[1],))
embeddings = embeddings[:, :matryoshka_dim]   # ← 切り詰め
embeddings = F.normalize(embeddings, p=2, dim=1)  # ← 最後にL2正規化

順番が要点です。切り詰めた後にL2正規化をやり直す。 切り詰めるとベクトルの長さが変わるので、正規化を飛ばすとコサイン距離が歪みます。「次元を削ったら精度が落ちた」の一部は、Matryoshka が効いていないのではなく、この再正規化を飛ばしているだけです。

Ollama の /api/embed には dimensions パラメータがあり、これを使うのが最も簡単です。自前で切り詰める場合だけ、上の順序を守ってください。

削る前に、削る必要があるかを測ってください。 1万チャンク × 768次元 × 4バイトで約30MBです。個人や小規模チームの文書量なら、次元削減が効くほど大きくならないことの方が多いはずです。

5. 検索がよくなったことを、どう確かめるか

ここまでの修正が効いたかどうかは、体感ではなく数で見ます。難しい仕組みは要りません。

  1. 想定質問を20〜30件書く。 実際に使う人に書いてもらうのが一番です。自分で書くと、自分の文書に都合のいい質問になります。
  2. 各質問について、本来ヒットすべきチャンクを手で決めておく。
  3. 上位5件にそれが入っている割合を測る(Recall@5)。
const recallAt5 = cases.filter((c) => search(c.question, 5).includes(c.expectedChunkId)).length / cases.length;

これだけで、接頭辞を付けた前後・チャンクサイズを変えた前後を比較できる状態になります。比較できないまま調整を続けるのが、ローカルRAGで一番時間を溶かすパターンです。

生成側の品質を測りたくなりますが、順序としては後です。上位5件に正解が入っていなければ、どんなモデルでも正しくは答えられません。 検索が先、生成は後です。

6. LM Studio を使う場合

LM Studio はOpenAI互換のサーバを立てられるので、/v1/embeddings を叩く形になります。埋め込みモデルをロードしておく必要がある点だけ Ollama と違いますが、3章の3つの落とし穴はそのまま当てはまります。特にタスク接頭辞は、どのランタイムを使うかではなくモデル側の作法なので、LM Studio でも同じように必要です。

「ランタイムを替えたら精度が上がるのでは」と考えたくなったときは、先に3章を確認してください。ランタイムは埋め込みの中身を変えません。

7. ローカルでやめる判断

正直に書きます。ローカルRAGが向かない条件があります。

  • 文書が全部プロンプトに入る量しかない。 RAGは「入り切らない」を解く手段です。入り切るなら、全文をそのまま渡した方が速く正確です。まず全文投入で足りるか測ってください。
  • 更新が毎分単位で走る。 インデックス更新の設計が本体になります。そこまで来たら、検索側は専用のサービスに寄せた方が総コストは下がります。
  • 複数人で同時に使う。 SQLiteのファイル1つという手軽さが、そのまま制約になります。この段階で pgvector(PostgreSQL)に移すのが素直です。
  • 「精度が出ない」の原因がまだ特定できていない。 構成を変える前に3章を通してください。構成の問題に見えて実装の問題であることが、経験上かなり多いです。

逆に、外に出せない文書があり、量がプロンプトに入り切らず、更新が日次程度なら、この記事の構成はそのまま実用になります。GPUも要りません。


ローカルRAGの精度は、モデルの大きさよりも「検索側を正しく組めているか」で決まります。この記事で挙げた3つは、どれもエラーを出さずに動いてしまうため、知らないと原因の候補に上がりません。逆に言えば、一度知ってしまえば再発しません。まず3章を通してから、モデル選定やチャンクサイズの調整に進んでください。

よくある質問

ローカルRAGはクラウドのRAGより精度が低いのですか?
「ローカルだから低い」わけではありません。実際に効くのは埋め込みモデルの選定とチャンク設計、そして本文で挙げる3つの実装ミス(非推奨エンドポイント・truncateの既定値・タスク接頭辞の付け忘れ)です。生成側のモデルサイズより、検索側が正しく組めているかの方が支配的に効きます。
GPUは必要ですか?
埋め込みだけならCPUでも実用になります。埋め込みモデルは生成モデルより桁違いに小さく、初回のインデックス作成が一度だけ重いという性質なので、夜間に流す運用でも成立します。応答側のLLMを快適に動かしたい場合はVRAMが効きますが、それは検索精度とは別の話です。
sqlite-vec と pgvector はどちらを選ぶべきですか?
1台のPCで完結させるなら sqlite-vec、すでにPostgreSQLが本番にあるなら pgvector です。sqlite-vec はサーバもDockerも要らずファイル1つで動く代わりに pre-v1 で破壊的変更がありえます。チームで共有し、バックアップや権限管理を既存の運用に乗せたい段階になったら pgvector に移す、という順序が素直です。
既存のドキュメントが少ない場合でもRAGにする意味はありますか?
数十ファイル程度なら、RAGにせず全文をそのままプロンプトに入れた方が速く正確なことがあります。RAGは「全部入れると入り切らない」を解く手段なので、入り切るなら解く問題がありません。まず全文投入で足りるかを測ってから決めてください。
社内文書を扱うので外に出せません。ローカルRAGで要件を満たせますか?
Ollama も sqlite-vec もローカルで完結するため、埋め込み・検索・保存のどの段階でも外部送信は発生しません。ただし「外に出ていないこと」は設計ではなく検証で担保すべきものなので、通信を遮断した状態で通しで動くことを一度確認してください。監査で問われるのは構成図ではなく挙動です。なお Ollama の API(11434番ポート)には認証が無く、既定では 127.0.0.1 だけで待ち受けます。OLLAMA_HOST=0.0.0.0 で LAN に公開するならファイアウォールか認証付きリバースプロキシを必ず挟んでください。モデルを取得する ollama pull だけはネット接続が要るので、遮断テストの前に済ませておきます。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

オープンウェイトLLMの自社ホスティング基盤を、構築から本番運用・コスト最適化まで

vLLM 上で Qwen3-8B-AWQ と4bit量子化 Llama-3 を本番稼働させ、スポットGPU(Tesla T4)と Terraform で再現可能な推論基盤として運用した実績があります。量子化方式の選定、VRAMとスループットの設計、プロバイダ抽象によるモデル差し替え、強制停止からの再開まで、止まらない推論基盤を設計・実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい