163
146

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Mac mini(M4 Pro / 48GB)で「完全ローカル」のLLM + RAG環境を構築してみた

163
Last updated at Posted at 2026-07-27

はじめに

こんにちは。
トライベック株式会社の岡山です。

ローカルでLLM + RAGを検証したく、最近 Mac mini(M4 Pro / メモリ48GB)を購入しました。
「mini」なのは筐体だけで、価格は全くminiではありませんでしたが。

そもそもの動機は、クラウドのAIサービスに感じていた以下の懸念でした。

  • 資料や個人情報を外部サービスに送ることへの抵抗感
  • 従量課金のため、試行錯誤するほどコストが積み上がる
  • ネットワーク環境に依存する

これらを解決する選択肢が「ローカルLLM + ローカルRAG」です。手元のマシンで完結するため、情報は外に出ず、何度試しても無料です。

本記事では、Apple Silicon の Mac で、Ollama と LlamaIndex を使って完全ローカルのRAG環境を構築するまでの手順をまとめます。

本記事は「まず動かす」ことを目的とした導入記事です。精度チューニングや永続化については次回以降で扱う予定です。

環境

項目 内容
マシン Mac mini(2024)
チップ Apple M4 Pro
メモリ 48GB(ユニファイドメモリ)
OS macOS Tahoe 26.5.1

なぜ Apple Silicon はローカルLLMに向いているのか

ローカルLLMというとGPUのVRAM容量が話題になりますが、Apple Silicon にはユニファイドメモリという強みがあります。CPUとGPUがメモリを共有するため、搭載メモリの大部分をそのままモデルの読み込みに使えます。

一般的なPCでは「メモリは32GBあるがGPUのVRAMは8GB」といった構成になりがちで、この場合8GBがモデルサイズの上限になります。一方、M4 Pro / 48GB の場合、GPU が利用できるのはデフォルトで概ね 65〜75%(約32〜36GB) です。

実測ベースの目安として、この構成で快適に動くのは以下のあたりです。

  • 14B〜32Bクラス: 実用的な速度で動作。常用に向く
  • 70Bクラス: 低ビット量子化(IQ3_M等)で「かろうじて動く」レベル

つまり、14B〜32Bの高品質モデルを動かしつつ、RAGに必要な埋め込みモデルを同居させられるというのが、このマシンで狙えるラインになります。

構成するもの

今回構築する環境の全体像です。

役割 使うもの 補足
推論エンジン Ollama モデルの管理と実行を担当
生成モデル(LLM) qwen3:14b 回答文を生成する
埋め込みモデル bge-m3 文書をベクトル化する。日本語に強い
RAGフレームワーク LlamaIndex 検索と生成をつなぐ

rag-architecture.png

RAGでは「文書を検索するための埋め込みモデル」と「回答を作る生成モデル」の2種類のモデルが必要になります。ここが最初に混乱しやすいポイントでした。

導入方法(Mac版)

Step 1: Ollama のインストール

Homebrew でインストールします。

brew install ollama

インストール後、サーバーを起動します。

ollama serve

GUIアプリ版の Ollama をインストールしている場合、メニューバーに常駐した時点でサーバーは起動済みです。その状態で ollama serve を実行すると address already in use エラーになります。

起動確認は以下で行えます。

ollama list

Step 2: モデルの取得

生成モデルと埋め込みモデルを取得します。

# 生成モデル(総合バランス型)
ollama pull qwen3:14b

# 比較用に少し大きいモデルも
ollama pull gemma3:27b

# 埋め込みモデル(日本語含む多言語に強い)
ollama pull bge-m3

bge-m3 は埋め込み専用のため、チャットには使えません。RAGの裏方として動きます。

Step 3: 動作確認

RAGを組む前に、モデル単体が動くか確認します。

ollama run qwen3:14b

プロンプトが表示されたら日本語で話しかけてみます。

>>> 自己紹介して

返答が返ってくれば成功です。終了は /bye です。

生成速度を数値で確認したい場合は --verbose を付けます。

ollama run gemma3:27b --verbose

回答後に eval rate(tokens/s)が表示されます。体感の目安としては、15 tokens/s 以上あれば常用で快適、10を下回ると「待っている」感覚が出てきます。

14B と 27B を同じ質問で比較しておくと、常用モデルを決める判断材料になります。私の環境では、日常使いは 14B、じっくり考えさせたいときは 27B という使い分けに落ち着きました。

Step 4: Python環境の準備

ここからRAGの構築に入ります。プロジェクト用のディレクトリと仮想環境を作成します。

mkdir ~/local-rag && cd ~/local-rag
python3 -m venv venv
source venv/bin/activate

必要なパッケージをインストールします。

pip install llama-index \
            llama-index-llms-ollama \
            llama-index-embeddings-ollama \
            llama-index-vector-stores-chroma \
            chromadb

PDFを読み込ませる場合は追加で以下も必要です。

pip install pypdf

Step 5: 資料の配置

読み込ませたい文書を data ディレクトリに置きます。

mkdir data
# ./data にPDFやテキストファイルを配置

最初は1〜2ファイル程度から始めるのがおすすめです。ファイル数が多いと初回の埋め込み処理に時間がかかり、動作確認がしづらくなります。

Step 6: RAGスクリプトの作成

rag.py として以下を保存します。

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding

# 使用するモデルを指定
Settings.llm = Ollama(model="qwen3:14b", request_timeout=180)
Settings.embed_model = OllamaEmbedding(model_name="bge-m3")

# ./data の文書を読み込み → 埋め込み → インデックス化
docs = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(docs)

# 質問する
qe = index.as_query_engine(similarity_top_k=4)
resp = qe.query("この資料の要点を3つ教えて")
print(resp)

実行します。

python rag.py

配置した資料の内容に基づいた回答が返ってくれば、ローカルRAGの完成です。

コードの解説

わずか十数行ですが、RAGの主要な要素が含まれています。

rag-pipeline-flow.png

Settings.llmSettings.embed_model
生成モデルと埋め込みモデルを分けて指定しています。どちらもローカルのOllamaを参照するため、外部への通信は発生しません。

SimpleDirectoryReader
ディレクトリ内のファイルを自動で読み込みます。PDF、テキスト、Markdownなど、拡張子に応じて適切なパーサーが選ばれます。

VectorStoreIndex.from_documents
文書を適当な長さに分割(チャンク化)し、埋め込みモデルでベクトル化してインデックスを構築します。初回はここに時間がかかります。

similarity_top_k=4
質問に対して、関連度の高い上位4件のチャンクを取得してLLMに渡すという指定です。この数字を変えると回答の傾向が変わるため、精度チューニングの最初の調整ポイントになります。

トラブルシューティング

よくあるエラーと対処法:

  • Connection refused エラー: Ollamaが起動していません。ollama list でモデル一覧が返るか確認してください。LM Studio を併用している場合、そちらではなくOllama側が起動している必要があります。
  • address already in use エラー: Ollamaが既に起動しています。GUIアプリ版が常駐している場合に発生します。ollama serve は不要です。
  • 初回実行が異常に遅い: 埋め込み処理は文書量に比例します。bge-m3 が全文をベクトル化しているためで、異常ではありません。まずは少ないファイル数で試してください。
  • PDFが読み込めない: pip install pypdf が必要です。
  • モデル読み込み時にメモリ不足: GPUに割り当てるメモリの上限を引き上げられます。以下は再起動でリセットされます。
    sudo sysctl iogpu.wired_limit_mb=40960
    

48GBを活かすためのポイント

MLX形式を優先する
Apple Silicon 向けに最適化された MLX形式のモデルは、同じパラメータ数でも GGUF より高速・省メモリで動作します。Ollama や LM Studio でモデルを選ぶ際、-mlx タグが付いたものがあればそちらが有利です。

モデルの同居に注意する
RAGでは生成モデルと埋め込みモデルが同時にメモリを消費します。32Bクラスを常用したい場合、埋め込みには軽量な bge-m3 を選んでおくと余裕が生まれます。

まずは軽いモデルから始める
最初から大きなモデルを選ぶと、動作が遅く「うまく動いていないのか、単に遅いだけなのか」の切り分けが難しくなります。14Bで一通り動かしてから拡張するのが結果的に近道でした。

GUIツールという選択肢

今回はPythonで構築しましたが、コードを書かずにRAGを試す方法もあります。

  • AnythingLLM: RAG専用のGUIツール。文書をドラッグ&ドロップするだけでナレッジベースが作れます
  • LM Studio: モデルの検索・ダウンロード・実行をGUIで完結できます。MLX形式のモデルを探しやすいのが利点です

いずれも裏側で Ollama を利用できるため、今回構築した環境と併用可能です。「まずRAGがどういうものか体感したい」という場合は、GUIから入るのも十分に有効な選択肢だと思います。

まとめ

Mac mini(M4 Pro / 48GB)で、完全ローカルのLLM + RAG環境を構築しました。

改めて振り返ると、ポイントは以下の3点でした。

  1. Apple Silicon のユニファイドメモリは、ローカルLLMと相性が良い
  2. RAGには生成モデルと埋め込みモデルの2種類が必要
  3. LlamaIndex を使えば、RAGの基本構成は十数行で書ける

情報が外部に出ず、何度試しても無料という環境は、試行錯誤のハードルを大きく下げてくれます。RAGの仕組みを記事として整理したときは概念的な理解に留まっていましたが、実際に手を動かすと「チャンク分割」「検索件数」といったパラメータの意味が体感として掴めました。

ただし、現状のスクリプトには課題も残っています。実行のたびに埋め込み処理が走るため、文書が増えると待ち時間が無視できません。

追記(2026-08-07):いただいたコメントへの回答

公開後にコメントで2点のご要望をいただいたので、実際に測ってみました。

  • モデルの出自について@ikeday さん) — 業務のセキュリティ要件によっては、提供元の国が問われることがある。その場合の構成も知りたい
  • similarity_top_k について@emailindetail さん) — 値を変えたときに回答の傾向がどう変わるのか、具体例が見たい

あわせて、本文の最後に課題として残した「実行のたびに埋め込み処理が走る」問題も解決したので、追記3として付記します。

結論から書くと、こうなりました。

  1. 埋め込みモデルは、提供元の異なるものへ置き換えても精度を維持できました
  2. similarity_top_k に単一の正解値はありません — 上げたことで回答が悪化した質問が実際に出ました

追記1:モデルの出自と選択肢

提供元が中国のモデルは業務のセキュリティ要件上採用しづらい、という @ikeday さんのご指摘への回答です。

論点の整理

本文で使った2モデルは、いずれも中国の組織が公開したものです。

役割 モデル 提供元
生成 qwen3:14b Alibaba Cloud
埋め込み bge-m3 BAAI

どちらも性能・ライセンスともに優れたモデルで、避けるべき理由が技術的にあるわけではありません。ローカル実行では推論時の外部通信が発生しないため、入力した文書が提供元に送られることもありません。

一方で、官公庁案件や金融系では、技術的な評価とは別に「提供元の国」が調達要件に明記されている場合があります。技術的な良し悪しと、組織として採用できるかは別の判断で、後者は開発者が説得して覆せるものではありません。要件が来たときに差し替えられる状態にしておく、というのが現実的な備えになります。

なお、出自とライセンスは独立した軸です。混同すると選定を誤ります。

モデル 出自 ライセンス
qwen3 / bge-m3 中国 Apache 2.0 / MIT(緩い)
gemma3 / embeddinggemma 米国 Gemma Terms(独自・利用禁止事項あり
llama3.3 米国 Llama Community License(独自
gpt-oss / phi4 米国 Apache 2.0 / MIT

出自の制約で差し替えた先が、今度はライセンスで引っかかることがあります。両方の確認が必要です。

測り方

架空の社内文書5本(就業規則・情報セキュリティ規程・製品仕様書・経費精算マニュアル・障害対応手順書)に対し、13問の質問セットで比較しました。質問は3タイプに分けています。

タイプ 何を見るか
pinpoint(7問) 「パスワードの最低文字数は?」 語がそのまま本文にある
paraphrase(4問) 「リモートワークは週何日まで?」(本文は「在宅勤務」) 言い換えを意味で拾えるか
overview(2問) 「期限が定められているものを列挙して」 複数文書にまたがる

指標は file_hit@k(正解が載っている文書が上位k件にすべて入った質問の割合)。チャンクサイズは全モデル 512 で統一しています。

実測:埋め込みモデル

モデル 提供元 / 出自 ライセンス file_hit@4 paraphrase 検索時間
bge-m3 BAAI / 中国 MIT 92% 100% 231ms
snowflake-arctic-embed2 Snowflake / 米国 Apache 2.0 92% 100% 277ms
embeddinggemma Google / 米国 Gemma Terms 85% 100% 85ms
granite-embedding:278m IBM / 米国 Apache 2.0 85% 75% 118ms

bge-m3 は今回も最高スコアで、日本語での評価の高さは実測でも裏づけられました。そのうえで snowflake-arctic-embed2 が完全に同点で、言い換え質問も 100%。要件で提供元が制約される場合にも、同等の選択肢があることが確認できました。

下位2つも用途次第です。embeddinggemma検索が3倍速い(85ms)反面、複数文書にまたがる質問を落としました。622MB と小さいので、単一文書へのピンポイント検索なら有力です。

差し替えは2行です。

Settings.llm = Ollama(model="gpt-oss:20b", request_timeout=600)
Settings.embed_model = OllamaEmbedding(model_name="snowflake-arctic-embed2")

実測:生成モデル

埋め込みを snowflake-arctic-embed2 に固定し、top_k=4 で比較しました。なお本文の比較では gemma3:27b を使いましたが、ここではパラメータ数の近い gemma3:12b を対象にしています。

モデル 提供元 / 出自 正解キーワード一致 生成速度 平均生成時間
qwen3:14b Alibaba / 中国 88% 23.1 tok/s 18.2秒
gemma3:12b Google / 米国 88% 26.6 tok/s 5.7秒
gpt-oss:20b OpenAI / 米国 96% 47.6 tok/s 9.6秒

gpt-oss:20b が最も正確で、かつ倍近く速いという結果でした。20B と最も大きいのに速いのは MoE(Mixture of Experts)構成で、推論時に動くパラメータが一部だけだからです。パラメータ数だけ見て「重そう」と判断していたので、これは意外でした。

qwen3:14b の平均18.2秒は思考過程を挟むためで、回答の質自体は良好です(下の例では3モデル中で最も丁寧に答えています)。RAG のように参考情報を渡す用途では、その思考時間が効きにくい、という相性の問題に見えます。

Q. リモートワークは週に何日までできますか?

qwen3:14b   在宅勤務は原則として週3日を上限とします。ただし、育児・介護その他の
            事情がある場合は、所属長の承認により週5日まで認めることがあります。

gemma3:12b  育児・介護その他の事情がある場合は、所属長の承認により週5日まで認める。

gpt-oss:20b 原則として週3日までです。ただし、育児・介護等の事情がある場合は
            所属長の承認を得れば週5日まで認められます。

gemma3:12b だけが原則の「週3日」を落としています。ただしこのモデルが常にこう答えるわけではなくtop_k=1 にすると正しく答えます(後述)。モデルの比較は top_k の条件込みでしか語れない、というのが正直なところです。

結論

この文書とこの質問セットでは、提供元の異なるモデルへ置き換えても精度は保てました。 一般論として「どちらが優れている」という話ではなく、要件が来たときに選択肢がある、というのが実測でわかったことです。架空文書5本・13問という小規模な検証なので、実務の文書量や専門用語では違う結果になり得ます。手元の文書で同じように質問セットを作って測るのが確実だと思います。

なお、コメントの返信で候補に挙げた multilingual-e5-largephi4、国産の ELYZA / PLaMo は今回の計測に含めていません。手元にすぐ引けたモデルに絞ったためで、国産モデルは別途あらためて測ってみようと思います。

選定時に踏んだ落とし穴

どちらもエラーが出ないまま精度だけ落ちる種類のもので、今回いちばんの収穫でした。

1. 埋め込みモデルのコンテキスト長

bge-m3snowflake-arctic-embed2 は 8K ですが、embeddinggemma は 2K、granite-embedding は 512 しかありません。LlamaIndex の既定チャンクサイズ(1024トークン)のまま granite-embedding に渡すと、チャンクの後半が切り捨てられ、そこにあった情報は検索に引っかかりません。今回は全モデルの下限に合わせて 512 で統一しました。

2. 日本語のチャンク分割

SentenceSplitter は既定で nltk の punkt tokenizer を使いますが、punkt は英語向けで、日本語の「。」を文末と認識しません。句点で分割する関数を渡すだけで直ります。

def ja_sentence_split(text: str) -> list[str]:
    return [p for p in re.split(r"(?<=[。!?\n])(?![)」』】])", text) if p.strip()]

Settings.node_parser = SentenceSplitter(
    chunk_size=512, chunk_overlap=64,
    chunking_tokenizer_fn=ja_sentence_split,   # ここ
)

追記2:similarity_top_k を変えると何が変わるか

similarity_top_k を変えたときに回答の傾向がどう変わるのか具体例が見たい、という @emailindetail さんのご要望への回答です。

本文で「この数字を変えると回答の傾向が変わる」と書いておきながら、具体例を示していませんでした。snowflake-arctic-embed2 + gemma3:12b で 1 → 12 まで振ってみます。

top_k file_hit pinpoint paraphrase overview 投入トークン 生成時間
1 85% 100% 100% 0% 371 2.9秒
2 85% 100% 100% 0% 525 3.8秒
4 92% 100% 100% 50% 996 4.9秒
8 100% 100% 100% 100% 1,786 8.4秒
12 100% 100% 100% 100% 1,786 7.9秒

質問の種類で最適値がはっきり分かれました。 pinpoint と paraphrase は top_k=1 の時点で既に 100% で、増やしても1ミリも上がりません。一方 overview は top_k=8 で初めて 100%、1〜2 では全滅です。

今回の文書はチャンク数が8しかないため、top_k=8 以上は実質「全チャンクを渡している」状態です(8 と 12 で投入トークンが同じなのはそのため)。実務の文書量ではこうはならず、必要な値は文書量に応じて変わります。

回答で見る

数字より、実際の回答のほうが分かりやすいと思います。上げると良くなる例と、悪くなる例が両方出ました。

例1:上げると悪くなった質問「リモートワークは週に何日までできますか?」

top_k=1   原則として週3日を上限とし、育児・介護等の事情がある場合は
          所属長の承認により週5日までです。          ← 正解

top_k=4   育児・介護その他の事情がある場合は、所属長の承認により
          週5日まで認める。                          ← 「週3日」が消えた

top_k=1 が最も正確でした。関連度の低いチャンクが混ざったことで、原則(週3日)が例外(週5日)に埋もれています。検索は成功しているのに、渡す情報を増やしたせいで回答が劣化した例です。

例2:上げないと答えられない質問「期限が定められているものを、資料全体から列挙してください」

top_k=1   資料に記載がありません。

top_k=12  * 障害報告書作成期限: 復旧後5営業日以内
          * 経費精算申請期限: 経費が発生した月の翌月10日まで
          * 顧客への状況連絡期限(S1障害): 1時間ごと

情報が複数文書に散っているため、top_k=1 では「記載がありません」になります。

同じ設定で、一方の質問には最適で、もう一方には不適。 これが similarity_top_k の実像でした。

実務での目安

  • 想定質問が読めるなら、その種類に合わせる(一点集中型なら小さく、横断型なら大きく)
  • 読めないなら 4〜6 から始める
  • 「資料に記載がありません」が多いなら上げる
  • 回答が散漫、または聞いていない情報が混ざるなら下げる

追記3:毎回埋め込みが走る問題

本文の最後に書いた課題です。Chroma に保存して再利用すれば解決します。

client = chromadb.PersistentClient(path="./storage")
collection = client.get_or_create_collection("docs_arctic2_512")
store = ChromaVectorStore(chroma_collection=collection)

if collection.count() > 0:
    index = VectorStoreIndex.from_vector_store(store)   # 埋め込みは走らない
else:
    docs = SimpleDirectoryReader("./data").load_data()
    ctx = StorageContext.from_defaults(vector_store=store)
    index = VectorStoreIndex.from_documents(docs, storage_context=ctx)

ポイントは、コレクション名に埋め込みモデル名とチャンクサイズを含めることです。ベクトルの次元も意味空間もモデルごとに違うため、混在させると検索結果が壊れます。これもエラーになりません。今回は4モデル × 複数の top_k で何十回も検索しましたが、埋め込み処理は最初の1回だけで済みました。

追記(2026-08-07)のまとめ

  1. モデルの出自は、差し替え可能な設計にしておけば要件変更に耐えられます。 今回は埋め込み・生成とも同等以上の選択肢がありました
  2. similarity_top_k に単一の正解値はありません。 一点集中型は top_k=1 で既に満点、横断型は 8 が必要。増やしたことで回答が悪化した例も出ました
  3. 静かに劣化する設定が多い。 コンテキスト長超過、日本語への英語用文分割器、インデックスの使い回し。どれもエラーが出ません
  4. 測る仕組みを先に作ると速い。 検索精度の評価に生成モデルは不要で、埋め込み4モデルの比較は13秒で終わりました

参考

163
146
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
163
146

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?