概要
本記事では、インターネット接続なし・GPU非搭載のPC環境でも動作するRAG(Retrieval-Augmented Generation)システムの構築手順を紹介します。
社内ドキュメントや機密情報を外部サービスに送信せずにAIに問い合わせたい場合、すべてをローカルで完結させる構成が有効です。ただし本記事の構成はPoC・技術検証を目的としており、本番運用は想定していません。
この記事でできること
- オフライン環境でのRAGシステム構築と動作確認
- TXT / Markdown / PDFドキュメントへの日本語質問応答
- 機密情報を外部に送信しないローカル完結構成の実現
この記事でできないこと
- 本番運用レベルのパフォーマンス・信頼性の確保
- 大規模ドキュメントへの高速応答(CPUのみのため応答に数十秒かかります)
- 高精度な複雑推論(SLMの性能限界があります)
本番稼働を検討する場合は、GPUを搭載した環境でより大きなモデルを使用することを推奨します。
技術スタック
| 役割 | 使用技術 |
|---|---|
| LLM推論環境 | LM Studio |
| LLM | Qwen2.5-7B-Instruct(GGUF形式、Q5_K_M) |
| RAGフレームワーク | LangChain |
| ベクトルDB | Chroma |
| Embeddingモデル | intfloat/multilingual-e5-large |
| 対応ドキュメント形式 | TXT / Markdown / PDF |
| API互換 | OpenAI互換API(localhost:1234) |
モデル選定の理由
Qwen2.5-7B-Instruct を選定した理由は以下のとおりです。
- 日本語の生成品質が高い
- RAG用途(コンテキストに基づく回答)に適している
- 7Bクラスのモデルとしては安定性が高い
- GGUF形式により量子化でメモリ使用量を抑えられる
量子化形式はQ5_K_Mを使用しています。Q4_K_MやQ4_K_Sと比較した場合、安定後の応答速度はQ5_K_Mが最速であることを確認しています(詳細は後述)。
intfloat/multilingual-e5-large を選定した理由は以下のとおりです。
- 日本語を含む多言語に対応している
- all-MiniLM-L6-v2(英語特化)と比較して日本語検索精度が大幅に向上する
- ローカル実行が可能
前提条件・確認環境
- OS:Windows 11
- Python:3.12
- LM Studio:インストール済み(LM Studio公式サイト)
- GPU:不要(CPUのみで動作確認済み)
- インターネット接続:初回セットアップ時のみ必要(モデルダウンロード)
必要なPythonパッケージ
langchain
langchain-openai
langchain-community
langchain-chroma
langchain-huggingface
langchain-text-splitters
sentence-transformers
pypdf
rank-bm25
httpx
セットアップ手順
1. LM Studioのセットアップ
LM Studioを起動し、以下の手順でモデルをダウンロードしてAPIサーバーを起動します。
- LM StudioでQwen2.5-7B-InstructのGGUFファイル(Q5_K_M)を検索してダウンロードする
- ダウンロードしたモデルをロードする
- 左メニューの「Local Server」からAPIサーバーを起動する(デフォルトポート:1234)
APIサーバーが起動していることを確認します。
curl http://localhost:1234/v1/models -UseBasicParsing | Select-Object -ExpandProperty Content
モデル一覧がJSON形式で返ってくれば正常です。
2. 仮想環境の作成とパッケージのインストール
python -m venv rag-env
.\rag-env\Scripts\Activate.ps1
pip install langchain langchain-openai langchain-community langchain-chroma langchain-huggingface langchain-text-splitters sentence-transformers pypdf rank-bm25 httpx
3. ディレクトリ構成
ragenv/
├── docs/ # 取り込むドキュメントを配置
│ └── example.txt
├── vector_db/ # Chromaが自動生成
├── ingest.py # ドキュメントをVectorDBに登録
├── check_db.py # VectorDBの登録状況確認
└── rag.py # RAG本体(対話式)
コード
ingest.py(ドキュメント登録)
ドキュメントをチャンクに分割してChromaに登録します。docs/ 配下のTXT / Markdown / PDFを自動で読み込みます。
import os
os.environ["HF_HUB_OFFLINE"] = "1" # オフライン動作の設定(初回ダウンロード後に有効)
from pathlib import Path
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader, PyPDFLoader
DOCS_DIR = "./docs"
VECTOR_DIR = "./vector_db"
EMBED_MODEL = "intfloat/multilingual-e5-large"
def load_documents(docs_dir: str):
docs = []
path = Path(docs_dir)
for file in path.rglob("*"):
if file.suffix == ".txt":
loader = TextLoader(str(file), encoding="utf-8")
docs.extend(loader.load())
print(f" [TXT] {file}")
elif file.suffix == ".md":
loader = TextLoader(str(file), encoding="utf-8")
docs.extend(loader.load())
print(f" [MD] {file}")
elif file.suffix == ".pdf":
loader = PyPDFLoader(str(file))
docs.extend(loader.load())
print(f" [PDF] {file}")
return docs
def split_documents(docs):
splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n\n", "\n", "。", "、", " ", ""],
)
return splitter.split_documents(docs)
def build_vectordb(chunks):
embeddings = HuggingFaceEmbeddings(
model_name=EMBED_MODEL,
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
vectordb = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=VECTOR_DIR,
)
return vectordb
if __name__ == "__main__":
print("=== ドキュメントロード ===")
docs = load_documents(DOCS_DIR)
print(f"ロード完了: {len(docs)} ファイル")
print("\n=== チャンク分割 ===")
chunks = split_documents(docs)
print(f"チャンク数: {len(chunks)}")
print("\n=== VectorDB構築 ===")
vectordb = build_vectordb(chunks)
print(f"登録完了: {vectordb._collection.count()} チャンク")
print("\n=== 登録内容サンプル ===")
for i, chunk in enumerate(chunks[:3]):
print(f"\n[{i+1}] {chunk.page_content[:80]}...")
check_db.py(登録確認)
import os
os.environ["HF_HUB_OFFLINE"] = "1"
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
embeddings = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
vectordb = Chroma(
persist_directory="./vector_db",
embedding_function=embeddings,
)
print(f"登録チャンク数: {vectordb._collection.count()}")
rag.py(RAG本体)
EmbeddingとベクトルDBを起動時に1回だけロードし、以降はキャッシュを再利用します。
import os
os.environ["HF_HUB_OFFLINE"] = "1" # オフライン動作の設定
import httpx
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
_embeddings = None
_vectordb = None
_chain = None
def get_rag_chain():
global _embeddings, _vectordb, _chain
if _chain is not None:
return _chain
# 1. Embedding(初回のみweightsロード)
_embeddings = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
# 2. VectorDB
_vectordb = Chroma(
persist_directory="./vector_db",
embedding_function=_embeddings,
)
# 3. Retriever
retriever = _vectordb.as_retriever(
search_type="mmr",
search_kwargs={"k": 3, "fetch_k": 10},
)
# 4. Prompt
prompt = ChatPromptTemplate.from_messages([
("system",
"あなたは社内ナレッジベースのアシスタントです。\n"
"【厳守ルール】\n"
"1. 以下のコンテキストに書かれている情報だけを使って回答すること\n"
"2. コンテキストに存在しない情報は絶対に追加しないこと\n"
"3. 推測・補完・一般知識による回答は禁止\n"
"4. コンテキストに情報がない場合は「ドキュメントに記載がありません」とだけ答えること\n"
"5. 回答はコンテキストの表現をそのまま使うこと\n\n"
"コンテキスト:\n{context}"),
("human", "{question}"),
])
# 5. LLM(LM Studio互換API)
llm = ChatOpenAI(
base_url="http://localhost:1234/v1",
api_key="lm-studio",
model="qwen2.5-7b-instruct@q5_k_m",
temperature=0.1,
max_tokens=512,
max_retries=1,
http_client=httpx.Client(timeout=180.0),
)
# 6. Chain
_chain = (
{"context": retriever | _format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
print("[RAG] 初期化完了")
return _chain
def _format_docs(docs):
# 重複チャンクの除去とコンテキスト長の制限
seen = set()
unique_docs = []
for doc in docs:
if doc.page_content not in seen:
seen.add(doc.page_content)
unique_docs.append(doc)
result = []
total = 0
for doc in unique_docs:
if total + len(doc.page_content) > 1500:
break
result.append(doc.page_content)
total += len(doc.page_content)
return "\n\n---\n\n".join(result)
if __name__ == "__main__":
chain = get_rag_chain()
print("終了するには 'exit' または 'quit' と入力してください。\n")
while True:
try:
q = input("\n質問: ").strip()
if not q:
continue
if q.lower() in ("exit", "quit"):
print("終了します。")
break
answer = chain.invoke(q)
print(f"\n回答: {answer}")
except KeyboardInterrupt:
print("\n終了します。")
break
実行手順
初回セットアップ
# 1. ドキュメントをdocs/フォルダに配置する
# 2. VectorDBにドキュメントを登録する
python ingest.py
# 3. 登録内容を確認する
python check_db.py
# 4. RAGを起動する
python rag.py
ドキュメントを追加・更新した場合
# VectorDBを削除して再インデックスする
rm -r vector_db
python ingest.py
python rag.py
動作確認
以下は実際の動作例です(ドキュメントにエラーコードの説明が記載されている場合)。
質問: E-1001 とはどういったエラーですか
回答: DB接続失敗です。原因はDATABASE_URLの設定誤り、またはDBサービス未起動です。
対処法は.envファイルの接続文字列を確認し、docker-compose psでDB状態を確認することです。
質問: ドキュメントに記載のない質問
回答: ドキュメントに記載がありません
ドキュメントに記載のない内容については「ドキュメントに記載がありません」と返答します。これはプロンプトでLLMの一般知識による補完を禁止しているためです。
量子化形式の比較(参考)
Qwen2.5-7B-Instruct の量子化形式ごとの応答時間を計測しました(CPU環境、同一プロンプト3回平均)。
| 形式 | 2〜3回目の平均応答時間 | ファイルサイズ目安 |
|---|---|---|
| Q4_K_S | 約24秒 | 約4.1GB |
| Q4_K_M | 約20秒 | 約4.4GB |
| Q5_K_M | 約15秒 | 約5.1GB |
初回はモデルのロードが発生するため時間がかかりますが、2回目以降は上記の時間で応答します。RAG用途では量子化による回答品質の差はほぼ出ないため、応答速度とメモリ容量のバランスでQ5_K_MまたはQ4_K_Mの選択を推奨します。
ハマりどころと対処法
構築中に発生した問題と対処法をまとめます。
ImportError: No module named 'langchain.text_splitter'
LangChainのバージョンアップにより、text_splitterのモジュールパスが変更されています。
# 誤
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 正
from langchain_text_splitters import RecursiveCharacterTextSplitter
ValidationError: Extra inputs are not permitted(query_instruction)
langchain_huggingface の新バージョンでは query_instruction パラメータが廃止されています。該当行を削除してください。
# query_instruction="query: " の行を削除する
embeddings = HuggingFaceEmbeddings(
model_name=EMBED_MODEL,
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
オフライン環境で起動時にエラーが発生する
起動のたびにHugging FaceへEmbeddingモデルの更新確認リクエストが送信されます。以下の環境変数を設定することでオフライン動作になります。
import os
os.environ["HF_HUB_OFFLINE"] = "1"
rag.py と ingest.py の両方に追加してください。初回のモデルダウンロード後に設定することで、以降はキャッシュから読み込みます。
UNEXPECTED key: embeddings.position_ids の警告
embeddings.position_ids | UNEXPECTED
この警告はモデルロード時に表示されることがありますが、動作上の問題はありません。無視して問題ありません。
LLM呼び出しでタイムアウトが発生する
CPU環境ではLLMの応答生成に時間がかかります。httpx.Client でタイムアウトを明示的に設定してください。
llm = ChatOpenAI(
...
http_client=httpx.Client(timeout=180.0),
)
ChatOpenAI の timeout パラメータはバージョンによって内部のhttpxクライアントに反映されないことがあるため、http_client で直接指定する方が確実です。
本番運用に向けた検討事項
本記事の構成はPoC目的であり、本番運用には以下の対応が必要です。
| 課題 | 対策 |
|---|---|
| 応答速度(CPUで15〜30秒) | GPU環境への移行、より軽量なモデルの選定 |
| LLMの規模 | 13B以上のモデルまたはファインチューニング済みモデルへの変更 |
| 検索精度 | Hybrid Search(BM25+ベクトル検索)、Rerankerの導入 |
| 可用性・スケーラビリティ | Ollamaやvllmなど本番向け推論サーバーへの移行 |
| セキュリティ | APIエンドポイントの認証、ドキュメントアクセス制御 |
まとめ
LM Studio + LangChain + Chroma + Qwen2.5-7B-Instructの組み合わせで、GPUなし・オフライン環境でのローカルRAGが実現できました。
- 機密情報を外部に送信しないローカル完結構成
- TXT / Markdown / PDFへの日本語質問応答
- Embeddingに
intfloat/multilingual-e5-largeを使用することで日本語検索精度を確保 - CPU環境での応答時間は15〜30秒程度
PoC・技術検証の用途であれば十分に実用的な構成です。本番運用を検討する場合はGPU環境とより大きなモデルへの移行を検討してください。


