「ローカル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) | |
|---|---|---|
| 入力キー | input | prompt |
| 複数同時 | できる(配列を渡す) | できない(1件ずつ) |
| 戻り値キー | embeddings(配列の配列) | embedding(平坦な配列) |
日本語の解説記事はいまも /api/embeddings を載せているものが多く、コピーすると次の2つが同時に起きます。
- 戻り値の形が違うので、
embeddings[0]のつもりで書いたコードがundefinedを掴む。これは気づけます。 - バッチできないので、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/embed の truncate パラメータについて、公式ドキュメントはこう書いています。
truncate: truncates the end of each input to fit within context length. Returns error iffalseand context length is exceeded. Defaults totrue
つまり既定では、コンテキスト長を超えた入力は末尾を切り落として処理されます。エラーは出ません。
これがローカル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 assearch_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. 検索がよくなったことを、どう確かめるか
ここまでの修正が効いたかどうかは、体感ではなく数で見ます。難しい仕組みは要りません。
- 想定質問を20〜30件書く。 実際に使う人に書いてもらうのが一番です。自分で書くと、自分の文書に都合のいい質問になります。
- 各質問について、本来ヒットすべきチャンクを手で決めておく。
- 上位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章を通してから、モデル選定やチャンクサイズの調整に進んでください。