🦔

Docker×OllamaでRAG環境をゼロから構築する完全手順(社内マニュアル自動生成編)

に公開

Docker×OllamaでRAG環境をゼロから構築する完全手順(社内マニュアル自動生成編)

ローカルGPUで社内ドキュメント検索AI(RAG)を動かしたい。
この記事では、Docker + Ollama + LangChain + FastAPI + Streamlitを組み合わせて
完全にオフラインなAIマニュアル検索環境を構築するまでを詳細に解説します。
最終的に「パスワード変更手順は?」のような質問に
LLMが根拠付きで回答
できるところまで動作確認します。
社内で作成する場合プロキシ設定を加える必要があります。


🎯 目標

  • Llama 3(Ollama)をバックエンドLLMとして利用
  • LangChain + ChromaDBでRAG(Retrieval Augmented Generation)を構築
  • FastAPIによるAPIサーバー
  • StreamlitでWeb UI
  • 完全ローカル(ネットワーク遮断環境でも可)

🧩 1. ディレクトリ構成

rag-poc/
├── backend/
│   ├── app/
│   │   └── main.py
│   ├── Dockerfile
│   └── requirements.txt
├── ui/
│   ├── app.py
│   ├── requirements.txt
│   └── Dockerfile
├── data/
│   ├── docs/
│   └── chroma/
├── docker-compose.yml
├── Makefile
└── .env

⚙️ 2. 依存ファイル一覧

.env

COMPOSE_PROJECT_NAME=ragpoc
OLLAMA_MODEL=llama3
EMBED_MODEL=BAAI/bge-m3
API_PORT=8000
UI_PORT=8501
USE_GPU=true

backend/requirements.txt

fastapi
uvicorn[standard]
langchain
langchain-community
langchain-text-splitters
chromadb
sentence-transformers
pydantic
pypdf
httpx
python-multipart

backend/app/main.py

import os
from fastapi import FastAPI, UploadFile, File
from pydantic import BaseModel
from typing import List
from langchain_community.document_loaders import TextLoader, PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.llms import Ollama
from langchain.chains import RetrievalQA

OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://ollama:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
EMBED_MODEL = os.getenv("EMBED_MODEL", "BAAI/bge-m3")
CHROMA_DIR = os.getenv("CHROMA_DIR", "/data/chroma")
DOCS_DIR = os.getenv("DOCS_DIR", "/data/docs")

app = FastAPI(title="RAG Manual API")

_embeddings = None
_vectorstore = None
_qa = None

def load_docs_from_dir(path: str):
    docs = []
    for root, _, files in os.walk(path):
        for fn in files:
            fp = os.path.join(root, fn)
            ext = os.path.splitext(fn)[1].lower()
            if ext in [".txt", ".md"]:
                docs += TextLoader(fp, encoding="utf-8").load()
            elif ext == ".pdf":
                docs += PyPDFLoader(fp).load()
    return docs

def ensure_pipeline():
    global _embeddings, _vectorstore, _qa
    if _embeddings is None:
        _embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL)
    if _vectorstore is None:
        os.makedirs(CHROMA_DIR, exist_ok=True)
        _vectorstore = Chroma(persist_directory=CHROMA_DIR, embedding_function=_embeddings)
    if _qa is None:
        llm = Ollama(model=OLLAMA_MODEL, base_url=OLLAMA_BASE_URL)
        _qa = RetrievalQA.from_chain_type(
            llm=llm,
            retriever=_vectorstore.as_retriever(search_kwargs={"k": 3}),
            chain_type="stuff",
            return_source_documents=True
        )
    return _qa, _vectorstore

@app.post("/ingest")
def ingest():
    qa, vector = ensure_pipeline()
    docs = load_docs_from_dir(DOCS_DIR)
    if not docs:
        return {"status": "no_docs", "message": f"No docs found in {DOCS_DIR}"}
    splitter = RecursiveCharacterTextSplitter(chunk_size=600, chunk_overlap=80)
    splits = splitter.split_documents(docs)
    vector.add_documents(splits)
    vector.persist()
    return {"status": "ok", "chunks": len(splits)}

class Query(BaseModel):
    question: str

@app.post("/query")
def query(q: Query):
    qa, _ = ensure_pipeline()
    res = qa({"query": q.question})
    sources = []
    for d in res.get("source_documents", []):
        meta = d.metadata.copy()
        sources.append({
            "source": meta.get("source", ""),
            "page": meta.get("page", None),
            "snippet": d.page_content[:300]
        })
    return {"answer": res["result"], "sources": sources}

ui/app.py

import streamlit as st
import os, requests

API_URL = os.getenv("API_URL", "http://localhost:8000")

st.title("社内マニュアル検索AI (RAG Demo)")
query = st.text_input("質問を入力してください(例:パスワード変更手順は?)")

if st.button("検索") and query:
    r = requests.post(f"{API_URL}/query", json={"question": query}, timeout=600)
    j = r.json()
    st.subheader("回答")
    st.write(j.get("answer", "(no answer)"))
    src = j.get("sources", [])
    if src:
        st.subheader("参照元")
        for i, s in enumerate(src, 1):
            with st.expander(f"根拠 {i}: {os.path.basename(s.get('source',''))}"):
                st.write(s.get("snippet",""))

docker-compose.yml

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    ports: ["11434:11434"]
    volumes:
      - ollama_models:/root/.ollama
    environment:
      - OLLAMA_KEEP_ALIVE=24h
      - NVIDIA_VISIBLE_DEVICES=all
      - NVIDIA_DRIVER_CAPABILITIES=compute,utility
    gpus: all
    healthcheck:
      test: ["CMD", "ollama", "list"]
      interval: 10s
      timeout: 5s
      retries: 15
    profiles: ["gpu"]

  api:
    build: { context: ./backend }
    container_name: rag-api
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
      - OLLAMA_MODEL=${OLLAMA_MODEL}
      - EMBED_MODEL=${EMBED_MODEL}
      - CHROMA_DIR=/data/chroma
      - DOCS_DIR=/data/docs
    volumes:
      - ./data:/data
    depends_on:
      ollama:
        condition: service_healthy
    ports:
      - "${API_PORT}:8000"
    restart: unless-stopped

  ui:
    build: { context: ./ui }
    container_name: rag-ui
    environment:
      - API_URL=http://api:8000
    depends_on:
      - api
    ports:
      - "${UI_PORT}:8501"
    restart: unless-stopped

volumes:
  ollama_models:

Makefile

-include .env
export $(shell sed -ne 's/^\([^#=]*\)=\(.*\)/\1/p' .env)
.PHONY: up down logs pull-model ingest

up:
	@if [ "$$(grep -i '^USE_GPU=true' .env)" ]; then \
		docker compose --profile gpu up -d --build; \
	else \
		docker compose up -d --build; \
	fi

down:
	docker compose down

logs:
	docker compose logs -f

pull-model:
	docker compose exec ollama ollama pull $(OLLAMA_MODEL)

ingest:
	curl -X POST http://localhost:$(API_PORT)/ingest

🚀 3. 起動手順

# GPU対応で起動
docker compose --profile gpu up -d --build

# モデル取得(初回のみ)
docker compose exec ollama ollama pull llama3

# ドキュメント登録
mkdir -p data/docs
printf "## パスワード変更手順\n1. 設定を開く\n2. セキュリティを選択\n3. 新しいパスワードを入力\n" > data/docs/sample_manual.md
make ingest

# クエリ送信
curl -s -X POST http://localhost:8000/query \
  -H "Content-Type: application/json" \
  -d '{"question":"パスワード変更手順は?"}' | python3 -m json.tool

✅ 4. 実行結果

{
  "answer": "パスワード変更手順: 1. 設定を開く 2. セキュリティを選択 3. 新しいパスワードを入力",
  "sources": [
    {
      "source": "/data/docs/sample_manual.md",
      "page": null,
      "snippet": "## パスワード変更手順\n1. 設定を開く\n2. セキュリティを選択\n3. 新しいパスワードを入力"
    }
  ]
}

✅ RAG検索が正しく動作し、
登録したMarkdownの内容からAIが手順を要約して回答。


💡 5. 改善ポイントと発展

  • 精度を上げたい場合

    • .envEMBED_MODELBAAI/bge-m3 に設定
    • make ingest で再構築
  • 参照数の調整

    • _vectorstore.as_retriever(search_kwargs={"k": 3}) を変更(2〜5が目安)
  • より自然な回答にしたい場合

    • OLLAMA_MODEL=llama3:instruct に変更
  • 監視/保守

    • /health エンドポイント追加で死活監視可能
  • 自動更新

    • 社内WikiやConfluence APIと連携して/ingest自動化も容易

🔍 6. よくあるトラブル

症状 原因 対処
api depends on undefined service "ollama" --profile gpu を付けず起動 docker compose --profile gpu up -d
could not select device driver "" with capabilities: [[gpu]] NVIDIA toolkit未設定 sudo nvidia-ctk runtime configure --runtime=docker
ModuleNotFoundError: langchain_community.* LangChain v0.2以降 pip install -U langchain-community langchain-text-splitters
/uploadpython-multipart エラー 未インストール pip install python-multipart

🧭 7. 所感

RAG環境をここまでDockerで一気に立ち上げられるようになったのは、
LangChainの分割リファクタOllamaの軽量化 のおかげ。
特に、Llama 3 のローカル動作と ChromaDB の組み合わせは、
中小企業や閉域ネットワークでも現実的に「社内専用ChatGPT」を構築できる。

日本の現場においても、

  • 社内ナレッジ検索
  • マニュアル生成
  • 業務FAQボット

といった領域でこの構成はそのまま転用可能です。


🔗 参照リンク


🧩 著者コメント

この構成は、最初の起動までに少し手間がかかりますが、
一度立ち上がると 社内AIアシスタント基盤の原型 になります。

今後の記事では以下を掘り下げます👇

  • Confluence/Notionからの自動同期
  • 定期ingest+バージョン管理
  • Embedding精度比較(日本語 vs 英語モデル)

✅ これで「ローカルRAG構築の完全版」が完成です。
GPUがある環境なら即動作、ない場合でもCPUで検証可能。
社内AI導入のPoCとして、まずはこの環境から始めてみてください。

Discussion