ローカルLLMでRAGを始めるには、文書を小さなかたまりに分けて埋め込み(ベクトル)に変え、質問に近いかたまりだけを探してモデルに渡します。
まず試すならLM Studioのチャットに文書を添付する方法、同じ資料を何度も使うならOpen WebUIのナレッジベース、仕組みを自分で組みたいならOllamaの埋め込みAPIを使う方法が向いています。
この記事は、OllamaやLM Studioでモデルを動かせていて、社内の手順書や自分のメモを根拠に答えさせたい方に向けたものです。
内容は、2026年10月18日に読んだOllama・Open WebUI・LM Studioの公式ドキュメントの記載をもとにしています。
実機での再現は含まないため、回答の精度や処理時間は、手元の文書とモデルで確かめてください。
後半のPythonスクリプトは、Ollamaの代わりに同じ形の応答を返す模擬サーバーを立て、2026年10月18日にPython 3.13で、文書の分割・索引の保存・近いかたまりの検索・質問文の組み立てが動くことだけを確かめました。
Ollamaをまだ入れていない場合は、先にOllamaの使い方入門でモデルを1つ動かしておくと、原因を切り分けやすくなります。
RAGでしていること
LM Studioの公式ドキュメントでは、RAGは長い文書から関係のある部分を探し出し、参考としてモデルに渡す方法と説明されています(LM Studio公式:Chat with Documents)。
モデルが一度に読める量(コンテキスト長)には上限があるため、文書を丸ごと渡せないときに、必要な部分だけを選んで渡すのが目的です。
| 工程 | していること | 使うもの |
|---|---|---|
| 分割 | 文書を数百字〜数千字のかたまり(チャンク)に分ける | スクリプトやアプリの設定 |
| 埋め込み | かたまりごとに、意味を表す数値の並び(ベクトル)を作る | 埋め込みモデル |
| 保存 | ベクトルと元の文章を対にして保存する | ファイルやベクトルデータベース |
| 検索 | 質問もベクトルにして、近いかたまりを上から数件選ぶ | コサイン類似度など |
| 回答 | 選んだかたまりを質問の前に付けてモデルに渡す | チャット用のモデル |
回答するモデルと、検索に使う埋め込みモデルは別のものです。
Ollamaの公式ドキュメントでは、索引を作るときと質問するときで同じ埋め込みモデルを使うよう案内されています(Ollama公式:Embeddings)。
3つの始め方
| 方法 | 向いている場面 | 準備 |
|---|---|---|
| LM Studioに文書を添付 | 1つの文書について、その場で質問したい | LM Studioとチャット用のモデル |
| Open WebUIのナレッジベース | 同じ資料の集まりを、何度も別の会話で使いたい | Open WebUI、Ollamaなどの接続先、埋め込みモデル |
| Ollamaの埋め込みAPIとPython | 分け方や検索の仕方を自分で決めたい | Ollama、埋め込みモデル、Python |
LM Studioで文書を添付する
LM Studioの公式ドキュメントでは、チャットに添付できる文書の形式は .docx・.pdf・.txt と書かれています。
文書がモデルのコンテキストに収まる長さなら、LM Studioは文書の中身を丸ごと会話に加えます。
長すぎる場合は、RAGに切り替えて関係のある部分だけを渡す仕組みです。
同じページでは、うまく探してもらうコツとして、資料に出てきそうな用語や言い回しを質問に入れることが挙げられています。
たとえば「休みの申請」と聞くより、規程で使われている「有給休暇」「申請期限」といった語を入れたほうが、該当する部分が選ばれやすくなります。
LM Studioの公式ドキュメントのオフライン利用の説明では、チャットに渡した文書の処理は手元で行われ、文書がアプリの外に出ることはないと書かれています(LM Studio公式:Offline Operation)。
LM Studioの導入とモデルの選び方は、LM Studioの使い方で説明しています。
Open WebUIのナレッジベースを使う
Open WebUIは、OllamaなどにつないでブラウザーからチャットできるWebアプリです。
資料の集まりを「ナレッジベース」として登録しておくと、別の会話でも同じ資料を検索させられます。
登録して質問するまで
公式ドキュメントの手順を、画面の日本語表示に合わせて並べると次のとおりです(Open WebUI公式:Knowledge Bases and Document Chat)。
- 「ワークスペース」の「ナレッジベース」を開き、「作成」から名前と説明を付けて作る
- まずは文字を選択できるPDFやテキストファイルを1つアップロードし、処理が終わるまで待つ
- チャットの入力欄で # を入力してナレッジベースを選ぶか、「ワークスペース」の「モデル」の「編集」でモデルに添付する
- 答えがファイルに書いてある質問をして、該当箇所を引用させる
- 回答と引用元を、元の文書と見比べる
画面の日本語表示は、Open WebUIの公式リポジトリにある日本語の翻訳ファイルで照合しました。
関連する部分だけ渡すか、全文を渡すか
公式ドキュメントでは、添付した資料の扱い方が2つ用意されています。
| 扱い方 | 画面の表示 | 向いている資料 |
|---|---|---|
| Focused Retrieval(既定) | 関連する部分を検索中 | 量が多く、一部だけが質問に関係する資料 |
| Full Context | ドキュメント全体を使用中 | 短い手順書や、いつも全体を参照させたい資料 |
Full Contextは文書を丸ごと毎回渡すため、コンテキスト長の小さいモデルでは会話の履歴が押し出されると書かれています。
ネイティブの関数呼び出しでは自動で渡されない
公式ドキュメントでは、ネイティブの関数呼び出し(Native)が既定で、この場合はモデルに添付したナレッジが自動では渡されないと説明されています。
モデルが検索用のツールを呼び出して、資料を取りにいく仕組みです。
ツールの呼び出しが苦手な小さいモデルでは資料が使われないことがあるため、公式ドキュメントは、ツールを使うようシステムプロンプトで指示する、ツールを確実に呼べるモデルを使う、添付をFull Contextに切り替える、といった対処を挙げています。
手元だけで処理したい場合
Open WebUIのオフライン利用の公式ドキュメントでは、チャット用のモデルがローカルでも、文書の読み取りや埋め込みの処理まで手元で行われるとは限らないと書かれています(Open WebUI公式:Run Open WebUI Offline)。
管理者設定のDocumentsの画面で、文書の読み取りと埋め込みにどの提供元が選ばれているかを確かめます。
OllamaとPythonで小さく作る
仕組みを確かめながら組みたい場合は、Ollamaの埋め込みAPI(/api/embed)とチャットAPI(/api/chat)だけで、RAGの流れを作れます。
埋め込みモデルを用意する
Ollamaのモデル一覧で、日本語の文書に使うなら候補になるのが embeddinggemma です。
Ollamaのモデルページでは、embeddinggemmaはGoogleの3億パラメーターの埋め込みモデルで、100以上の言語のデータで学習したと説明されています(Ollama:embeddinggemma)。
| モデル | 大きさ | コンテキスト | モデルページの説明 |
|---|---|---|---|
| embeddinggemma | 622MB | 2K | 100以上の言語のデータで学習。Ollama v0.11.10以降が必要 |
| nomic-embed-text | 274MB | 2K | 長いコンテキストを扱える埋め込みモデル。Ollama 0.1.26以降が必要 |
nomic-embed-text の説明はOllama:nomic-embed-textのページによります。
埋め込みモデルとチャット用のモデルは、それぞれ取得しておきます。
ollama pull embeddinggemma
ollama pull gemma4チャット用のモデルは例として gemma4 にしています。
手元のメモリに合うモデルの選び方は、ローカルLLMの日本語おすすめモデルを参考にしてください。
スクリプト
次のスクリプトは、フォルダーの中の .txt と .md を読み、索引ファイルを作ってから質問に答えさせます。
Pythonの標準ライブラリだけで動き、追加のパッケージは使いません。
# rag_mini.py:フォルダー内の .txt / .md を検索して、ローカルLLMに答えさせる
# 使い方:python rag_mini.py index 文書フォルダー
# python rag_mini.py ask "質問"
import json, math, sys, urllib.request
from pathlib import Path
OLLAMA = 'http://localhost:11434'
EMBED_MODEL = 'embeddinggemma' # 索引と質問で同じモデルを使う
CHAT_MODEL = 'gemma4' # ollama pull で入れたモデル名に変える
INDEX_FILE = 'rag_index.json'
CHUNK_CHARS = 500 # 1かたまりの最大文字数
TOP_K = 3 # 回答に渡すかたまりの数
def post(path, body):
req = urllib.request.Request(OLLAMA + path, data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=600) as r:
return json.load(r)
def embed(texts):
return post('/api/embed', {'model': EMBED_MODEL, 'input': texts})['embeddings']
def chat(prompt):
body = {'model': CHAT_MODEL, 'stream': False, 'options': {'num_ctx': 8192},
'messages': [{'role': 'user', 'content': prompt}]}
return post('/api/chat', body)['message']['content']
def split(text):
"""空行で段落に分け、CHUNK_CHARS を超えない範囲でまとめる"""
chunks, buf = [], ''
for para in [p.strip() for p in text.split('\n\n') if p.strip()]:
while len(para) > CHUNK_CHARS: # 長すぎる段落は途中で切る
if buf:
chunks.append(buf); buf = ''
chunks.append(para[:CHUNK_CHARS]); para = para[CHUNK_CHARS:]
if buf and len(buf) + len(para) + 1 > CHUNK_CHARS:
chunks.append(buf); buf = ''
buf = (buf + '\n' + para) if buf else para
if buf:
chunks.append(buf)
return chunks
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
def build_index(folder):
items = []
for f in sorted(Path(folder).rglob('*')):
if f.suffix.lower() in ('.txt', '.md'):
for i, c in enumerate(split(f.read_text(encoding='utf-8'))):
items.append({'source': f'{f.name}#{i + 1}', 'text': c})
for start in range(0, len(items), 32): # 32件ずつまとめて埋め込む
batch = items[start:start + 32]
for item, vec in zip(batch, embed([b['text'] for b in batch])):
item['vector'] = vec
Path(INDEX_FILE).write_text(json.dumps({'model': EMBED_MODEL, 'items': items}, ensure_ascii=False), encoding='utf-8')
print(f'{len(items)}件のかたまりを保存しました')
def search(question):
index = json.loads(Path(INDEX_FILE).read_text(encoding='utf-8'))
if index['model'] != EMBED_MODEL:
sys.exit('索引を作ったときと埋め込みモデルが違います。index からやり直してください')
q = embed([question])[0]
return sorted(index['items'], key=lambda it: cosine(q, it['vector']), reverse=True)[:TOP_K]
def ask(question):
hits = search(question)
context = '\n\n'.join(f'[{h["source"]}]\n{h["text"]}' for h in hits)
prompt = ('次の資料だけを根拠に、日本語で答えてください。'
'資料に書かれていないことは「資料にありません」と答えてください。\n\n'
f'# 資料\n{context}\n\n# 質問\n{question}')
print(chat(prompt))
print('\n参照:' + '、'.join(h['source'] for h in hits))
if __name__ == '__main__':
if len(sys.argv) == 3 and sys.argv[1] == 'index':
build_index(sys.argv[2])
elif len(sys.argv) == 3 and sys.argv[1] == 'ask':
ask(sys.argv[2])
else:
print('使い方:python rag_mini.py index 文書フォルダー / python rag_mini.py ask "質問"')動かし方
文書を1つのフォルダーにまとめ、先に索引を作ってから質問します。
python rag_mini.py index docs
python rag_mini.py ask "有給休暇は何日前までに申請しますか"回答の下に、参照したかたまりのファイル名と番号が表示されます。
文書を書き換えたり埋め込みモデルを変えたりしたときは、index からやり直します。
コードの動き
| 処理 | していること |
|---|---|
| split | 空行で段落に分け、500字を超えない範囲でまとめる。長すぎる段落は500字で切る |
| build_index | 32件ずつ /api/embed に送り、ベクトルを索引ファイルに保存する |
| search | 質問をベクトルにして、コサイン類似度の高い順に3件選ぶ |
| ask | 選んだ3件を資料として質問の前に付け、/api/chat に送る |
Ollamaの埋め込みのドキュメントでは、/api/embed は長さを1にそろえたベクトルを返し、多くの検索ではコサイン類似度を使うよう案内されています。
APIリファレンスでは、input に文字列の配列を渡すとまとめて埋め込めること、コンテキストを超えた入力は既定で切り詰められることが書かれています(Ollama公式:Generate embeddings)。
embeddinggemma のコンテキストは2Kなので、かたまりを大きくしすぎると後ろが切り捨てられます。
/api/chat は stream の既定が true のため、1回で全文を受け取れるよう false を指定しています。
PDFやWordの文書を使う場合は、先にテキストに書き出してからフォルダーに入れてください。
答えが外れるときに確かめること
モデルのコンテキスト長が足りない
Ollamaの公式ドキュメントでは、コンテキスト長の既定はVRAMの量で決まり、24GiB未満では4kと書かれています(Ollama公式:Context length)。
| VRAM | 既定のコンテキスト長 |
|---|---|
| 24GiB未満 | 4k |
| 24〜48GiB | 32k |
| 48GiB以上 | 256k |
Open WebUIのRAGのドキュメントでも、Ollamaのこの既定値はRAGの性能を大きく制限し、検索した資料の一部しか使われないことがあると注意されています(Open WebUI公式:Retrieval Augmented Generation)。
上のスクリプトでは num_ctx を8192にしていますが、コンテキストを広げるほど必要なメモリも増えます。
メモリが足りなくなった場合は、ローカルLLMでメモリが足りないときの切り分けの順に確かめてください。
かたまりの大きさと件数が合っていない
Open WebUIのRAGのトラブル対応のドキュメントでは、コンテキストが8K以下のローカルモデル向けに、次の設定が挙げられています(Open WebUI公式:Troubleshoot Document Retrieval)。
| 設定 | 値 | 理由(ドキュメントの説明) |
|---|---|---|
| チャンクサイズ | 1000トークン | 検索結果がコンテキストに収まるよう小さめにする |
| チャンクオーバーラップ | 100 | 標準的な重なり |
| トップ K | 3〜5 | 限られたコンテキストを使い切らないよう少なめにする |
| Full Context | オフ | 文書を丸ごと送る余裕がない |
同じページでは、1000トークンのかたまりを5件渡すと、資料だけで約5,000トークンを使うと説明されています。
8Kのモデルなら、システムプロンプトと会話の履歴に残るのは2,000〜3,000トークンほどになります。
文字が正しく読み取れていない
同じトラブル対応のページでは、モデルが資料を見ていないように見える一番多い原因は、文書の取り込みの段階にあると書かれています。
Open WebUIでは、ナレッジベースのファイルを開いて「インデックス済みテキスト」に切り替えると、検索の対象になっている文字を確かめられます。
画像だけのPDFのように、ここが空や欠けた状態なら、モデルではなく読み取りの設定を見直します。
埋め込みモデルを途中で変えた
Open WebUIのドキュメントでは、埋め込みモデルを変えたら、登録済みの文書のインデックスを作り直すよう書かれています。
古いモデルで作ったベクトルと新しいモデルで作った質問のベクトルは比べられないためで、上のスクリプトも索引のモデル名が違えば止まるようにしています。
始める前に決めておくこと
- 答えさせたい質問を3つほど書き出し、その答えがどの文書のどこにあるかを確かめておく
- 最初は数ファイルで試し、回答と引用元を見比べてから資料を増やす
- 社外秘の資料を使う場合は、文書の読み取りと埋め込みが手元で処理される設定になっているかを確かめる
- 資料に無いことは「資料にありません」と答えるよう、指示に書いておく
RAGは、資料に書いてあることを探して答えさせる仕組みで、資料に無いことまで正しく答えられるようになるわけではありません。
大事な数字や期限は、表示された引用元の文書で確かめてから使ってください。
