NTT DATA TECH
📝

LangChain v1 × Ollama。ローカル環境で完結する RAG 構築ガイド

に公開

こんにちは、MEKIKI X AIハッカソンもくもく勉強会の12日目を担当するYokoiです!
普段はモダナイゼーション案件で生成AIの活用支援をしています。プライベートではドライブとサウナにハマっています。
今日は、ローカル環境で完結するRAG構築について書いてみました。

2025年10月22日、ついに LangChain v1 がリリースされました。 API 設計やエージェント機能が大きく進化しましたが、ネット上にはまだ v0 系の情報が多く、最新仕様で実装しようとすると意外とつまずきがちです。

そこで本記事では、LangChain v1 と Ollama を使い、Windows ラップトップだけで完結する「完全ローカル RAG」をご紹介します。

外部クラウドへデータを送信しない構成なので、セキュリティ規定が厳しい組織でも安心して検証できます。「最新の LangChain をキャッチアップしたい」「ローカル環境で RAG を試したい」というエンジニアの方に向けて、実装の勘所を解説します。


LangChain と Ollama の概要

LangChain とは

LangChain は、LLM アプリケーション(RAG、エージェント、ツール呼び出しなど)を構築するためのフレームワークです。

特に v1 での目玉は、create_agent の追加でしょう。シンプルなインターフェースで高度なエージェントの開発が可能となりました。一方で、パッケージ構成が大きく整理され、旧バージョンのコードを動かすには移行作業が必要になる場合があります。

v1の変更ポイントの詳細については、ぜひこちらの記事もご覧ください。
遂にLangChain v1.0リリース! 新機能と移行のポイントをコード付きで分かりやすく解説!(Agentic RAG/MCP対応版)

Ollama とは

Ollama は、ローカル端末で LLM を簡単に動かせるツールです。
ollama pull [LLMモデル名] のようにコマンドだけでモデルを取得でき、API 経由の利用もシンプルです。
CPU でも動作し、Windows に正式対応しているため、ローカル RAG の構築に向いています。


なぜローカル RAG なのか

ローカル RAG を採用するメリットのひとつはセキュリティです。社内文書をクラウド(OpenAI 等)に送信する必要がないため、機密情報を扱うプロジェクトでも利用のハードルが下がります。

また、手元のラップトップPCだけで完結するため、RAGの実装イメージを手軽につかむことができます。


今回実装する RAG の構成

今回作る構成は、ざっくり言うと「ローカル LLM + ローカル Embedding + ローカルベクトルストア」です。クラウドには一切データを送りません。

イメージはこんな形です。

ポイントは以下です。

  • 文書読み込み・分割・埋め込み・検索・生成がすべてローカルで完結します
  • ローカル LLM と Embeddings は両方とも Ollama ベースで動かします
  • ローカル ベクトルストアは ChromaDB を利用します
  • LangChain v1 の Agent を使って「文書検索 → プロンプト生成 → LLM」の流れをつなぎます

実装の全体手順

最初に、今回の実装フローを整理しておきます。

  1. 開発環境の準備: Ollamaのインストール
  2. インデックス作成: 文書の読み込み・分割・ベクトル化
  3. RAG構築: 検索 + LLM をつないだ RAG チェーンの構築
  4. GUI実装: ブラウザ上でチャットするためのUI作成

以下では、この順番に沿って具体的なコードとともに見ていきます。


1. 開発環境の準備

想定環境

  • Windows 11
  • Python 3.10+
  • LangChain v1.0+
  • Ollama(Windows版)
  • ChromaDB(ローカルベクトルストア)
  • メモリ 8GB以上

Ollama のセットアップ(Windows)

  1. 公式サイトから Windows 用インストーラをダウンロードします。

  2. インストール後、PowerShell などでモデルを取得します。

# 推論用モデル
ollama pull gemma3:4b

# 埋め込み用モデル
ollama pull nomic-embed-text

今回はGoogleが開発した軽量なLLM Gemma 3 の4B版を推論用モデルとして利用します。
また、埋め込み用モデルとしては nomic-embed-text を利用します。

Python パッケージのインストール

pip install langchain langchain-community langchain-text-splitters langchain-ollama langchain-chroma pypdf unstructured openpyxl python-pptx docx2txt gradio

2. インデックス作成(文書の読み込み・分割・ベクトル化)

まずは、社内文書を検索可能な形式(ベクトル)に変換して保存します。
ここからは、文書の読み込み / 分割 / ベクトル化 に関する処理をピックアップして解説します。

まず、LangChainが提供しているドキュメントローダーを使って文書を読み込みます。
今回は、PDF / Word / PowerPoint / Excel / txt を読み込み対象にしています。

from langchain_community.document_loaders import (
    DirectoryLoader, Docx2txtLoader, PyPDFLoader, TextLoader,
    UnstructuredExcelLoader, UnstructuredPowerPointLoader
)

# 対応するファイルパターンとローダークラスの対応表
LOADER_CONFIG = [
    ("**/*.pdf", PyPDFLoader),
    ("**/*.docx", Docx2txtLoader),
    ("**/*.pptx", UnstructuredPowerPointLoader),
    ("**/*.xlsx", UnstructuredExcelLoader),
    ("**/*.txt", TextLoader),
]

その後、docs ディレクトリに配置されたファイルを読み込みます。

# ドキュメント配置ディレクトリ
DOCS_DIR = "docs"

def load_documents_with_metadata(docs_dir: str) -> List[Document]:
    """
    docs_dir 以下から PDF・Word・Excel・PowerPoint 等を読み込む。
    """
    documents: List[Document] = []

    for pattern, loader_cls in LOADER_CONFIG:
        print(f"[SCAN] pattern={pattern}")

        loader = DirectoryLoader(
            docs_dir,
            glob=pattern,
            loader_cls=loader_cls,
            show_progress=True,
            silent_errors=True,
        )

        # 指定パターンにマッチする全ファイルをロード
        for doc in loader.load():
            documents.append(doc)

            path = doc.metadata.get("source")
            print(f"  🟢 追加: {path}")

    return documents

docs = load_documents_with_metadata(DOCS_DIR)

そして、ドキュメントを小さなチャンクに分割し、LLMモデルのコンテキストウィンドウ内に収まるようにします。
特に重要なのは Chunking(分割) の設定です。 RecursiveCharacterTextSplitter を使い、文脈がなるべく途切れないように chunk_overlap(重なり)を持たせて分割しています。日本語の文書であれば、separators を含めるといった工夫を実施しています。

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", "。", " ", ""],
    length_function=len,
)
texts = splitter.split_documents(docs)

最後に、Embeddingモデルを使用してテキストを数値のベクトル表現に変換し、ベクターストアに格納します。
今回は、ベクターストアに格納するタイミングで、同時にベクトル表現への変換もしてくれます。
ベクターストアは、ベクトル表現の保存と検索に特化した特殊なデータベースで、RAG構築ではよく使われます。

from langchain_chroma import Chroma
from langchain_ollama import OllamaEmbeddings

# 利用する埋め込みモデル名
EMBEDDING_MODEL = "nomic-embed-text"
# Chroma の永続化ディレクトリ
CHROMA_DIR = "chroma_db"

embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)
vectorstore = Chroma(
    embedding_function=embeddings,
    persist_directory=CHROMA_DIR,
)
vectorstore.add_documents(documents=texts)

以上で、

  • 文書の読み込み
  • チャンク単位で分割
  • Embeddingモデルでベクトル化
  • ベクターストアにインデックスが保存

が完了した状態になります。

ソースコードの全行はこちらです。

`create_index.py`(ソースコード全行)
create_index.py
from typing import List

from langchain_chroma import Chroma
from langchain_community.document_loaders import (
    DirectoryLoader,
    Docx2txtLoader, PyPDFLoader,
    TextLoader,
    UnstructuredExcelLoader,
    UnstructuredPowerPointLoader
)
from langchain_core.documents import Document
from langchain_ollama import OllamaEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 利用する埋め込みモデル名
EMBEDDING_MODEL = "nomic-embed-text"
# ドキュメント配置ディレクトリ
DOCS_DIR = "docs"
# Chroma の永続化ディレクトリ
CHROMA_DIR = "chroma_db"

# 対応するファイルパターンとローダークラスの対応表
LOADER_CONFIG = [
    ("**/*.pdf", PyPDFLoader),
    ("**/*.docx", Docx2txtLoader),
    ("**/*.pptx", UnstructuredPowerPointLoader),
    ("**/*.xlsx", UnstructuredExcelLoader),
    ("**/*.txt", TextLoader),
]


def load_documents_with_metadata(docs_dir: str) -> List[Document]:
    """
    docs_dir 以下から PDF・Word・Excel・PowerPoint 等を読み込む。
    """
    documents: List[Document] = []

    for pattern, loader_cls in LOADER_CONFIG:
        print(f"[SCAN] pattern={pattern}")

        loader = DirectoryLoader(
            docs_dir,
            glob=pattern,
            loader_cls=loader_cls,
            show_progress=True,
            silent_errors=True,
        )

        # 指定パターンにマッチする全ファイルをロード
        for doc in loader.load():
            documents.append(doc)

            path = doc.metadata.get("source")
            print(f"  🟢 追加: {path}")

    return documents


def create_index():
    """
    指定ディレクトリ配下のドキュメントを読み込み、Chroma ベクターストアのインデックスを構築・更新する。

    処理フロー:
        1. ドキュメント読み込み
        2. テキスト分割
        3. ベクトル化して Chroma に追加
    """
    print("=== インデックス作成開始 ===")

    # 1. ドキュメント読み込み
    print("[STEP 1] ドキュメント読み込み中...")
    docs = load_documents_with_metadata(DOCS_DIR)
    if not docs:
        print("  ⚠ 読み込めるドキュメントがありません。処理を終了します。")
        return

    # 2. テキスト分割
    print("[STEP 2] テキスト分割中...")
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200,
        separators=["\n\n", "\n", "。", " ", ""],
        length_function=len,
    )
    texts = splitter.split_documents(docs)
    print(f"  📄 分割後チャンク数: {len(texts)}")

    # 3. ベクトルストアに登録
    print("[STEP 3] ベクターストア更新中...")
    embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)
    vectorstore = Chroma(
        embedding_function=embeddings,
        persist_directory=CHROMA_DIR,
    )
    vectorstore.add_documents(documents=texts)

    print("=== インデックス更新完了 ===")


if __name__ == "__main__":
    create_index()

docs フォルダを作成し、読み込ませたいファイル(PDF, Word, txtなど)を配置してください。
以下のコマンドでインデックスを作成します。

python create_index.py

ファイル数によりますが、10ファイル程度であれば数分で完了すると思います。


3. RAG構築(検索 + LLM をつないだ RAG チェーンの構築)

続いて、ユーザーの質問から「関連文書の検索 → コンテキスト付きプロンプト生成 → LLM で回答生成」までをつなげます。

LangChain v1 では、@dynamic_prompt デコレータを使ったミドルウェア定義により、検索ロジックをシンプルに注入できるようになりました。
今回は @dynamic_prompt を使って、ベクターストアから検索した関連文書をコンテキストに加えます。

@dynamic_prompt
def prompt_with_context(request: ModelRequest) -> str:
    """
    ベクターストアから関連ドキュメントを検索し、
    その内容をコンテキストとしてプロンプトに挿入するミドルウェア。
    """
    # 直近メッセージの text をクエリとして利用
    last_query = request.state["messages"][-1].text

    # ベクターストアから関連ドキュメントを検索
    retrieved_docs = vector_store.similarity_search(last_query)

    # 検索結果ドキュメントの本文を結合
    docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)

    # システムメッセージとして LLM に渡すプロンプト
    system_message = (
        "あなたは社内ドキュメントに詳しいアシスタントです。"
        "以下のコンテキストを元に、日本語で丁寧に回答してください。"
        "不明な点があれば、その旨を正直に伝えてください。:"
        f"\n\n{docs_content}"
    )

    return system_message

そして、LangChain v1 で実装された create_agentを使い、RAGを実装します。
先ほど実装した prompt_with_context メソッドをエージェントのミドルウェアとして渡すことで実現できます。

agent = create_agent(model, tools=[], middleware=[prompt_with_context])

最後に、質問文を直接記述して実行してみましょう。
ストリーミング形式で応答を表示するようにしているので、生成途中の回答を逐次表示してくれます。
今回はサンプルとして自己紹介のPowerPoint資料を格納し、横井一輝の趣味は?と質問してみます。

# サンプルクエリ
query = "横井一輝の趣味は?"

# Agent からストリーミング形式で応答を取得
for token, metadata in agent.stream(
    {"messages": [{"role": "user", "content": query}]},
    stream_mode="messages",
):
    if token.content_blocks:
        # 生成途中のトークンを逐次表示
        print(token.content_blocks[0]["text"], end="", flush=True)

ソースコード全行はこちらです。

`console_rag.py`(ソースコード全行)
console_rag.py
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, dynamic_prompt
from langchain_chroma import Chroma
from langchain_ollama import ChatOllama, OllamaEmbeddings

# 利用する LLM モデル名
LLM_MODEL = "gemma3:4b"
# 利用する埋め込みモデル名
EMBEDDING_MODEL = "nomic-embed-text"
# Chroma の永続化ディレクトリ
CHROMA_DIR = "chroma_db"


# ===== モデル/ベクターストアの初期化 =====

model = ChatOllama(model=LLM_MODEL)
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)

vector_store = Chroma(
    embedding_function=embeddings,
    persist_directory=CHROMA_DIR,
)


# ===== ミドルウェア: 検索コンテキスト付きプロンプト =====

@dynamic_prompt
def prompt_with_context(request: ModelRequest) -> str:
    """
    ベクターストアから関連ドキュメントを検索し、
    その内容をコンテキストとしてプロンプトに挿入するミドルウェア。
    """
    # 直近メッセージの text をクエリとして利用
    last_query = request.state["messages"][-1].text

    # ベクターストアから関連ドキュメントを検索
    retrieved_docs = vector_store.similarity_search(last_query)

    # 検索結果ドキュメントの本文を結合
    docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)

    # システムメッセージとして LLM に渡すプロンプト
    system_message = (
        "あなたは社内ドキュメントに詳しいアシスタントです。"
        "以下のコンテキストを元に、日本語で丁寧に回答してください。"
        "不明な点があれば、その旨を正直に伝えてください。:"
        f"\n\n{docs_content}"
    )

    return system_message


# ===== Agent の生成 =====

agent = create_agent(model, tools=[], middleware=[prompt_with_context])


if __name__ == "__main__":
    # サンプルクエリ
    query = "横井一輝の趣味は?"

    # Agent からストリーミング形式で応答を取得
    for token, metadata in agent.stream(
        {"messages": [{"role": "user", "content": query}]},
        stream_mode="messages",
    ):
        if token.content_blocks:
            # 生成途中のトークンを逐次表示
            print(token.content_blocks[0]["text"], end="", flush=True)

以下のコマンドで、コンソール上で回答が生成される様子を確認できます。

python console_rag.py

今回は以下の回答が生成されました。私の環境では、30秒ほどで回答の生成が始まり、45秒ほどで回答が完了しました。
自己紹介資料の内容とおおよそあっています。

横井一輝さんの趣味は、サウナと旅行、そして家庭菜園です。最近は特にパクチーを大量収穫したそうです。

以上が、先ほど示した構成図の

  • 関連文書検索
  • LLM 推論
  • 回答生成

をつないでいる部分になります。


4. GUI実装(ブラウザ上でチャットするためのUI作成)

最後に、ブラウザから使えるようにGUI化します。
Python の gradio ライブラリを使えば、手軽にチャットUIを作成できます。
RAGを実装するための主な処理は先ほどと同じなため、解説は割愛します。

`app.py`(ソースコード全行)
app.py
import gradio as gr
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, dynamic_prompt
from langchain_chroma import Chroma
from langchain_ollama import ChatOllama, OllamaEmbeddings

# 利用する LLM モデル名
LLM_MODEL = "gemma3:4b"
# 利用する埋め込みモデル名
EMBEDDING_MODEL = "nomic-embed-text"
# Chroma の永続化ディレクトリ
CHROMA_DIR = "chroma_db"


# ===== モデル/ベクターストアの初期化 =====

model = ChatOllama(model=LLM_MODEL)
embeddings = OllamaEmbeddings(model=EMBEDDING_MODEL)

vector_store = Chroma(
    embedding_function=embeddings,
    persist_directory=CHROMA_DIR,
)


# ===== ミドルウェア: 検索コンテキスト付きプロンプト =====

@dynamic_prompt
def prompt_with_context(request: ModelRequest) -> str:
    """
    ベクターストアから関連ドキュメントを検索し、
    その内容をコンテキストとしてプロンプトに挿入するミドルウェア。
    """
    # 直近メッセージの text をクエリとして利用
    last_query = request.state["messages"][-1].text

    # ベクターストアから関連ドキュメントを検索
    retrieved_docs = vector_store.similarity_search(last_query)

    # 検索結果ドキュメントの本文を結合
    docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)

    # システムメッセージとして LLM に渡すプロンプト
    system_message = (
        "あなたは社内ドキュメントに詳しいアシスタントです。"
        "以下のコンテキストを元に、日本語で丁寧に回答してください。"
        "不明な点があれば、その旨を正直に伝えてください。:"
        f"\n\n{docs_content}"
    )

    return system_message


# ===== Agent の生成 =====

agent = create_agent(model, tools=[], middleware=[prompt_with_context])


# ===== Gradio 用ハンドラ =====

def predict(message, history):
    """
    Gradio ChatInterface 用の推論関数。
    """
    partial = ""
    # Agent からストリーミング形式で応答を取得
    for token, metadata in agent.stream(
        {"messages": [{"role": "user", "content": message}]},
        stream_mode="messages",
    ):
        if token.content_blocks:
            partial += token.content_blocks[0]["text"]
            # 途中経過を逐次 Gradio に返す
            yield partial


if __name__ == "__main__":
    # Chat UI を構成
    demo = gr.ChatInterface(
        predict,
        title='ローカル RAG チャットボット',
        description="docs/ フォルダにある Word / PowerPoint / Excel / PDF / テキストを元に回答します。",
    )

    # Web UI を起動
    demo.launch()

以下のコマンドを実行してください。

python app.py

実行後、表示されるURL(例:http://127.0.0.1:7860)にアクセスすると、RAGチャットボットが開きます。


まとめ

本記事では、LangChain v1 と Ollama を組み合わせ、Windows ラップトップ上だけで完結するローカル RAG を構築しました。
「まずは手元で動かしてみたい」というPoCフェーズにおいて、この構成は有力な選択肢になります。ぜひ手元の環境で試してみてください!

NTT DATA TECH
NTT DATA TECH
設定によりコメント欄が無効化されています