OllamaでローカルRAGパイプラインを構築する方法

OllamaでローカルRAGパイプラインを構築する方法に関する記事のアイキャッチ画像 - OllamaでローカルRAGパイプラインを構築する方法 AI×コーディング

ローカルRAGとは、文書の検索と回答生成を自分の環境内だけで完結させる構成のこと。

社内文書をAIに検索させたい。けれど機密情報を外部のクラウドAPIへ送るのは避けたい。この両立を、OllamaとローカルのベクトルデータをPythonまたはTypeScriptでつなぐだけで実現できます。本記事でいうローカルRAGは、LLM推論・埋め込み生成・ベクトル保存・検索をすべてローカル環境で完結させる構成を指し、その範囲ではデータが手元のマシンから外に出ません。ただしOllamaのCloud Modelsや外部Web検索、クラウド型ベクトルDB、外部LLM APIを併用する場合は、その経路で外部へ送信される可能性があります。最小構成のデモなら、設定後モデルのダウンロード時間を除けば短時間で試せます。

この記事の要点

  • 文書の検索から回答生成まで、データが自分の環境の外へ出ない構成にできる
  • 処理は「取り込み・分割・ベクトル化・検索・生成」の5段で流れる
  • Ollamaを使えば、Python/TypeScriptどちらでも最小構成は短時間で動かせる

ローカルRAGが必要になる場面(API課金とデータ秘匿の壁)

RAG(Retrieval-Augmented Generation、検索拡張生成のこと。AIが答える前に外部の文書を検索し、その内容を踏まえて回答する仕組み)をクラウドのAI APIだけで組むと、業務で本格運用に入った途端に2つの壁にぶつかります。

ひとつはレイテンシ(応答までの待ち時間)のばらつき。クラウドの混雑状況やネットワーク経路によって、同じ質問でも返ってくる速度が変わります。社内ツールに組み込むと、この揺れは思った以上に効きます。普段は1秒で返る検索が混雑時だけ数秒かかると、ユーザーは「固まった」と感じて使わなくなる。

もうひとつがデータの扱いです。RAGは検索対象の文書を毎回AIに渡す必要があります。つまり社内の契約書・設計資料・顧客情報を、外部のサーバーへ送り続けることになる。コンプライアンス上これが通らない部署は珍しくありません。

解決の方向はシンプルです。検索対象の文書も、埋め込み生成も、回答生成もローカルに閉じてしまう。外部APIへ文書を渡す経路をそもそも作らなければ、機密データを送る場面自体を減らせます。

コスト面も無視できません。クラウドAPIは利用量に比例して課金が膨らむ料金体系が一般的で、文書を大量に検索させるRAGは入力トークン(AIが処理する文字のかたまり)が増えがち。検索のたびに長い文書を渡すぶん、想定より請求が伸びやすい構造になっています。ローカル実行ならこの従量課金そのものが発生しません。発生するのは電気代と自前のマシン代くらいです。

もちろんローカル化が万能というわけではない。後述する本番運用の落とし穴もありますし、相応のマシン性能も要ります。それでも「外に出せないデータをAIに扱わせたい」という要件があるなら、ローカルRAGは現実的な第一候補。

RAGパイプラインの全体構成と5つの処理段

RAGは名前だけ見ると大げさですが、実装の流れ自体はかなり素朴です。文書を扱いやすい形に変えて検索し、見つかった内容をAIに渡して答えさせる。処理を5つに分けて見ると、どこで何が起きているかがつかめます。

ローカルRAGの処理フロー(取り込み→分割→ベクトル化→検索→生成)ローカルRAGの処理フロー(5段)入力:ローカル文書(Markdown / PDF)1取り込み(Ingestion)Markdown・PDFを読み込みテキスト化2分割(Chunking)数百字程度のチャンクに切り分ける3ベクトル化(Embedding)各チャンクを意味ベクトルへ変換(ローカル埋め込み)4検索(Retrieval)質問に意味が近いチャンクを探し出す5生成(Generation)チャンクを文脈にLLMが回答を生成出力:根拠に基づく回答
図:ローカルRAGの5つの処理段。ローカル文書が取り込み→分割→ベクトル化→検索→生成を経て、根拠に基づく回答になる。
取り込み(Ingestion)
ローカルにあるMarkdownやPDFなどの文書を読み込み、テキストとして取り出す段階。
分割(Chunking)
長い文書を、検索に適した数百字程度の小さなかたまり(チャンク)に切り分ける。
ベクトル化(Embedding)
各チャンクを、意味を数値の並びで表したベクトルへ変換する。ここでローカルの埋め込みモデルを使う。
検索(Retrieval)
質問文も同じようにベクトル化し、意味が近いチャンクをベクトルストアから探し出す。
生成(Generation)
見つけたチャンクを文脈としてLLM(大規模言語モデル)に渡し、最終的な回答を作らせる。

取り込みで見落としがちなのが、PDFの抽出品質です。PDFを扱うなら、抽出したテキストが崩れていないかを最初に確認してください。見出し・表・脚注が混ざったままチャンク化すると、検索以前に元データが壊れている状態になります。OllamaやEmbeddingをどれだけ調整しても、入力が壊れていれば結果は直りません。

精度を左右するのは、分割とベクトル化です。地味な工程ですが、ここの作り込みでRAGの当たり外れが決まります。

分割が雑だと、検索はすぐ崩れます。たとえば規約文の「条件」と「例外」が別々のチャンクに分かれると、検索で片方しか拾えず、LLMはその一方だけを根拠にもっともらしい回答を作ってしまう。逆に文の途中で切れれば、意味の通らないチャンクが検索結果に並びます。

ベクトル化は、文章を座標のような数値に置き換える処理だと考えると分かりやすい。「料金プラン」と「価格表」のように表記が違っても意味が近ければ、ベクトル上では近い位置に来る。だからキーワードが完全一致しなくても、意味で文書を引っ張ってこられるわけです。従来のキーワード検索との決定的な違いがここにあります。

なぜローカル埋め込みモデルを分けて用意するのか

会話用のLLMと、ベクトル化に使う埋め込みモデルは、役割がまったく別物です。ここを混同すると最初のセットアップでつまずきます。

LLMは文章を読んで答えを生成する担当。一方の埋め込みモデルは、文章を検索用の数値に変換することだけに特化したモデルです。RAGでは両方が必要になります。質問と文書をベクトル化するのが埋め込みモデルで、最後に答えを書くのがLLMです。

なぜ分けるのか。埋め込み専用モデルは変換に最適化されているぶん軽く、大量の文書を一度にベクトル化する場面で速い。会話用の大きなモデルにこの処理をやらせるのは、性能の無駄遣いになりがちです。Ollamaの場合、この2種類をそれぞれ別に取得する必要があり、片方の取得忘れがよくある初歩のミスになっています。

Ollamaで最小構成を動かす手順(Python / TypeScript)

ここが実際に手を動かすパート。Ollama(ローカルでLLMや埋め込みモデルを動かすためのツール)を立ち上げ、必要なモデルを取得し、PythonかTypeScriptから呼び出すところまでを最小構成で通します。

ローカル環境のセットアップとモデル取得

まずOllamaをローカルで起動した状態にしておきます。そのうえで、ターミナルから必要なモデルを取得しましょう。コマンド例は次のとおりです。

ollama pull llama3
ollama pull nomic-embed-text

ollama pull は、指定したモデルをローカルにダウンロードするコマンドです。1行目で会話用のLLMを、2行目で埋め込み専用の nomic-embed-text を取得しています。前のセクションで触れたとおり、この2つは役割が違うので両方そろえる必要がある。なお埋め込みモデルは用途で選べます。日本語文書の検索精度を重視するなら、nomic-embed-text-v2-moe・bge-m3・paraphrase-multilingual など多言語対応モデルも候補になります。nomic-embed-text は英語ベンチマークで語られることが多いモデルなので、日本語の社内文書だと「近いはずの文が検索で上がってこない」ことがあります。日本語が主なら、最初から多言語モデルで試したほうが手戻りは少ない。

Ollamaでは、会話用のLLMと埋め込み用モデルは完全に別物として扱われます。ollama pull llama3 だけ実行して埋め込みモデルの取得を忘れると、ベクトル化の段階で「モデルが見つからない」というエラーになります。最初のセットアップでは、必ず両方をpullしてください。

オーケストレーションの最小構成イメージ

モデルがそろったら、プログラムからOllamaを呼び出します。ここでは全体をつなぐ処理の入口だけを、最小構成イメージとして示します。コピーしてそのまま動く完成品ではなく、呼び出しの形をつかむための骨格です。

TypeScriptの場合、公式クライアントを使った埋め込み生成の最小形はこうなります。

// index.ts
import { Ollama } from 'ollama';

const ollama = new Ollama({ host: 'http://127.0.0.1:11434' });

async function generateLocalEmbedding(text: string): Promise<number[]> {
  const response = await ollama.embed({
    model: 'nomic-embed-text',
    input: text,
  });
  return response.embeddings[0];
}

http://127.0.0.1:11434 は、ローカルで動くOllamaの既定の接続先。generateLocalEmbedding に文章を渡すと、その意味を表すベクトル(数値の配列)が返ってきます。この関数を文書のチャンクひとつひとつに適用していけば、ベクトル化の段が完成する流れです。

Pythonでも考え方は同じ。まず公式クライアントを入れます。

pip install ollama

そのうえで、非同期クライアントを初期化して埋め込みを呼び出します。

from ollama import AsyncClient

client = AsyncClient(host='http://127.0.0.1:11434')

async def generate_local_embedding(text: str) -> list[float]:
    response = await client.embed(
        model='nomic-embed-text',
        input=text,
    )
    return response['embeddings'][0]

どちらの言語でも、やっていることは「ローカルのOllamaに文章を送り、ベクトルを受け取る」だけです。返ってきたベクトルをベクトルストア(ベクトルを保存して類似検索ができるデータベース)に貯めていき、質問が来たら質問もベクトル化して近いものを探す。ここまでできれば、RAGの検索部分はほぼ形になります。あとは検索で拾ったチャンクをプロンプトに差し込み、「この文脈だけを根拠に答えて」とLLMへ渡します。

なお、Ollama まわりの関連トピックは別記事でも扱っています。ローカルの Ollama を API サーバーにして社内アプリから呼ぶ構成はOllama をローカル API サーバーにして社内アプリから LLM を呼ぶ、クラウドAIからローカル実行へ切り替える背景はクラウドAIが本人確認を要求?Ollamaでローカル実行に切り替える解決法で扱っています。実行環境そのものの選択肢はTextGenとは?旧text-generation-webuiの使い方・LM Studio/Ollamaとの違いもあわせてどうぞ。

PythonとTypeScriptのどちらを選ぶか迷う場合、既存のシステムに合わせるのが無難です。データ処理やAI系のライブラリが充実しているのはPython。Webアプリのバックエンドにそのまま組み込みたいならTypeScript、という分け方が現実的でしょう。

実測:この最小パイプラインを手元で動かした結果

ここまでの5段を、Ollama だけでローカル完結する形で実際に動かした結果です。埋め込みは nomic-embed-text、生成は gemma3:12b、ベクトル検索は依存を足さずに純 Python のコサイン類似度です。標準の Ollama(http://localhost:11434)で動く、追加ライブラリ不要のコードです(接続先を外部に向けると文書とクエリがそこへ送られる点はコード内のコメントにも記しています)。

"""Ollama だけでローカル完結する最小 RAG のサンプル。

使い方:
  1. Ollama を起動(既定で http://localhost:11434 を listen)。
  2. モデルを取得: ollama pull nomic-embed-text / ollama pull gemma3:12b
  3. 実行: python rag.py

既定の接続先はローカルなので、文書もクエリも外部には送られない。環境変数
OLLAMA_HOST を外部アドレスに向けた場合のみ、その接続先へ送られる。
"""

import json
import math
import os
import time
import urllib.request

# 接続先。既定はローカル。外部に向けるとデータがそこへ送られる。
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
EMBED_MODEL = "nomic-embed-text"
GEN_MODEL = "gemma3:12b"

# 中立な一般説明のサンプル文書。
DOCUMENTS = [
    "Ollama はローカル LLM を CLI とローカル API で動かすツール。GUI なしでサーバ常駐できる。",
    "llama.cpp は C/C++ 実装の推論エンジン。GGUF 量子化モデルを CPU/GPU で動かす。最小依存。",
    "LM Studio は GUI 中心のデスクトップアプリ。モデル検索・チャットを画面操作で行う。",
    "vLLM は GPU 前提の高スループット推論サーバ。多並列をさばく。本番配信向け。",
    "text-generation-webui はブラウザ UI で多数のバックエンドを切り替えられる。",
]

QUERY = "GUI を使わず CLI とサーバ常駐でローカル LLM を動かしたい。どれが向く?"


def _post(path, payload):
    """Ollama の API に JSON を POST してレスポンスを返す。"""
    data = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        OLLAMA_HOST + path,
        data=data,
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read().decode("utf-8"))


def embed(text):
    """テキストを埋め込みベクトルに変換する。単一入力なので embeddings[0]。"""
    res = _post("/api/embed", {"model": EMBED_MODEL, "input": text})
    return res["embeddings"][0]


def generate(prompt):
    """プロンプトから回答を生成する(非ストリーム)。"""
    res = _post(
        "/api/generate",
        {"model": GEN_MODEL, "prompt": prompt, "stream": False},
    )
    return res["response"]


def cosine(a, b):
    """2 ベクトルのコサイン類似度。"""
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    if na == 0 or nb == 0:
        return 0.0
    return dot / (na * nb)


def main():
    # 文書を埋め込む。
    t0 = time.perf_counter()
    doc_vectors = [embed(doc) for doc in DOCUMENTS]
    embed_doc_sec = time.perf_counter() - t0

    # クエリを埋め込む。
    t0 = time.perf_counter()
    query_vector = embed(QUERY)
    embed_query_sec = time.perf_counter() - t0

    print(f"埋め込み次元: {len(query_vector)}")
    print(f"文書の埋め込み: {embed_doc_sec:.2f} 秒({len(DOCUMENTS)} 件)")
    print(f"クエリの埋め込み: {embed_query_sec:.2f} 秒")

    # コサイン類似度で並べ替え、上位 2 件を取る。
    scored = [
        (cosine(query_vector, vec), doc)
        for vec, doc in zip(doc_vectors, DOCUMENTS)
    ]
    scored.sort(key=lambda x: x[0], reverse=True)
    top = scored[:2]

    print("\n上位検索結果:")
    for score, doc in top:
        print(f"  [{score:.3f}] {doc}")

    # 上位 2 件を文脈にして回答を生成する。
    context = "\n".join(f"- {doc}" for _, doc in top)
    prompt = (
        "次の参考情報だけを根拠に、質問へ簡潔に答えてください。\n\n"
        f"参考情報:\n{context}\n\n"
        f"質問: {QUERY}\n"
        "回答:"
    )

    t0 = time.perf_counter()
    answer = generate(prompt)
    gen_sec = time.perf_counter() - t0

    print(f"\n生成: {gen_sec:.2f} 秒")
    print("\n回答:")
    print(answer.strip())


if __name__ == "__main__":
    main()

実行すると、次の出力が得られました。次元・所要時間・検索スコアはいずれも実測値です(環境により変動します)。

埋め込み次元: 768
文書の埋め込み: 1.24 秒(5 件)
クエリの埋め込み: 0.07 秒

上位検索結果:
  [0.903] Ollama はローカル LLM を CLI とローカル API で動かすツール。GUI なしでサーバ常駐できる。
  [0.792] LM Studio は GUI 中心のデスクトップアプリ。モデル検索・チャットを画面操作で行う。

生成: 16.35 秒

回答:
Ollama が向きます。

768 次元の埋め込みでベクトル化し、検索は上位の Ollama(0.903)と次点(0.792)で素直に差が出ています。回答はクエリに対して妥当な候補を返し、いずれもローカルで完結して、文書もクエリも外部へ送っていません。小規模なら専用のベクトル DB を入れなくても、この構成で検索から回答まで通せます。

確認: 2026 年 6 月 / Ollama + nomic-embed-text + gemma3:12b。モデルやライブラリの更新で数値や挙動は変わります。

RAGパイプラインと検索拡張エージェントの違い

ここで一度、RAGとエージェントを分けて整理しておきます。この2つを混ぜると、最初から不要に複雑な設計になりがちです。似ていますが、設計の前提がまるで違います。

両者の違いは、ツールの有無でもメモリの有無でもありません。いちばん大きいのは制御フロー(処理の進め方)です。RAGパイプラインは「質問に答える」ための固定フロー。エージェントは「目標を達成する」ために、次に何をするかをその場で選びます。

表にすると、違いがはっきりします。

観点 RAGパイプライン 検索拡張エージェント
目的 質問に答える 目標を達成する
制御フロー 事前に決めた手順で進む 状況に応じて動的に判断する
動きの予測しやすさ 高い 低い
向いている用途 文書検索・FAQ応答 複数手順の作業の自動化

RAGパイプラインは、決められた順番で「検索して、答える」を実行します。流れが固定されているぶん、動きが読みやすく、デバッグもしやすい。今回作っているのはこちらです。

一方のエージェントは、「次に何をすべきか」をその場で判断しながら動きます。検索するか、ツールを呼ぶか、もう一度考え直すか。情報をどう集めるかを状況に応じて自分で決める、という点が決定的に違う。

最近は「Agentic RAG」という言葉が乱用ぎみで、ここも混乱の元です。チャットボットが文書を検索し、質問を書き換え、ツールをひとつ呼んだだけで「エージェント」と呼ばれることがあります。実際には検索ステップを少し足しただけのものまで、ひとくくりにエージェント扱いされがちです。

何を作るのかを先に決めておくと、後がラクです。社内文書に答えるFAQボットが目的なら、シンプルなRAGパイプラインで十分。動的な判断が要らないものに動的な仕組みを足すと、予測しづらく壊れやすくなります。まず固定フローのRAGで組み、それで足りない要件が出てきてから動的な要素を足す。この順番が安全です。

動かないときの切り分けと本番運用の落とし穴

ローカルRAGの厄介なところは、エラーが出ないまま間違える場面があること。プログラムは正常に動き、自然な日本語の回答も返ってくる。それなのに中身が事実と違う、ということが起きます。

RAGの切り分けでも、まず見るべきは「モデルの賢さ」ではなく実行ログです。AIエージェントのデバッグでもよく言われるように、問題のある実行を絞り込み、判断の流れを追い、必要なら外部の観測ツールで詳しく見る、という順番が基本になります。失敗の原因は、モデル本体よりも、プロンプトに渡すデータ不足やツール定義の曖昧さといった周辺にあることが多いです。

RAGに当てはめると、症状ごとに確認すべき場所が見えてきます。

回答が的外れなとき。まず疑うのは検索の段です。質問に対して、見当違いのチャンクが引っ張られていないか。検索で実際に取り出されたチャンクを目で見て確認するのが第一歩。ここがずれていれば、その先のLLMがどれだけ優秀でも答えは正しくなりません。

回答が文書の内容と食い違うとき。チャンクは正しく取れているのに答えがおかしいなら、分割が粗すぎて文脈が断片化している可能性があります。チャンクのサイズや区切り方を見直すと改善する場面が多い。

RAGは処理が成功して回答が返っても、検索が的外れだと、間違った内容が自然な文章のまま出力されます。エラーが出ないぶん気づきにくいのが怖いところ。本番に出す前に、「実際にどのチャンクを検索で拾ったか」をログに残し、回答とセットで確認できるようにしておいてください。コードの意図ではなく、実際に動いた記録を根拠にして原因を切り分けるのが鉄則です。

本番運用ではマシン性能も効いてきます。ローカル実行はクラウドの従量課金こそ発生しませんが、処理速度は手元のハードウェアに依存する。文書量が増えるほどベクトル化と検索の負荷が上がるので、扱う文書の規模に対してモデルとマシンが釣り合っているかは、運用前に一度試しておきたいところです。利用量に比例した課金が膨らむクラウドとは別の意味で、ローカルにはローカルの見極めポイントがあります。

まとめ

ローカルRAGは、社内文書の検索から回答生成までを自分の環境内で完結させる構成です。データが外部のAPIへ出ないため、機密情報を扱う部署でもAI検索を導入しやすくなります。

仕組みは「取り込み・分割・ベクトル化・検索・生成」の5段。OllamaでLLMと埋め込みモデルの2つを取得し、PythonかTypeScriptから呼び出せば、最小構成は短時間で動き始めます。精度を左右するのは分割とベクトル化の2段で、ここが雑だと検索もぶれる。

作る前に、RAGパイプライン(質問に答える固定フロー)と検索拡張エージェント(目標を達成する動的フロー)の違いを押さえておくと、無駄に複雑な設計を避けられます。そして本番では、エラーが出ないまま間違える落とし穴に注意。検索で実際に拾ったチャンクをログで確認できる状態にしておくことが、品質を保つ最低ラインになります。

まず手元のマシンでOllamaを起動し、ollama pull でLLMと埋め込みモデルの2つを取得するところから始めると、5段のうち最初の「動く実感」がつかめます。そこから自分の文書を少しずつ流し込んでいくと、ローカルRAGの全体像が手応えとしてつかめてきます。

よくある質問

Q. ローカルRAGは無料で使えますか?

Ollamaをローカルで使い、ローカルモデルだけを実行する範囲では、クラウドAPIのような従量課金は発生しません。かかるのは自分のマシンの電気代とハードウェア費用です。ただしOllama Cloud Modelsや外部APIを併用する場合は、別途料金や利用条件を確認する必要があります。また、モデルごとにライセンス条件が異なるため、商用利用時は各モデルの規約も確認してください。

Q. GPUは必須ですか?

必須ではありません。CPUだけでも動かせます。ただし処理速度はハードウェア性能に依存するため、文書量が多い場合や応答を速くしたい場合はGPUがあると快適です。まずは手元の環境で小さく試し、速度が足りなければ増強を検討する流れが現実的でしょう。

Q. 本当に社内文書を外部に送らず使えますか?

埋め込み生成とLLMの推論を外部ホストやCloud Modelsを使わずローカルのOllamaだけで行う構成なら、文書データは自分の環境の外へ出ません。注意したいのは、外部のクラウドモデルやWeb検索機能を併用した場合。その経路ではデータが外部へ渡るため、完全にローカルで閉じたい場合は外部接続を使わない構成にする必要があります。

Q. PythonとTypeScriptのどちらで作るべきですか?

どちらでも同じ構成を作れます。データ処理やAI系ライブラリの充実度を重視するならPython、Webアプリのバックエンドに直接組み込みたいならTypeScriptが向いています。既存システムで使っている言語に合わせるのが、結局いちばん手間が少ない選び方です。

参考資料

タイトルとURLをコピーしました