生成AIの活用が広がる一方、社内資料や個人情報など、クラウドにアップロードできないデータを扱う場面では、ローカル環境で安全にAIを利用したいというニーズが高まっています。
最近登場したCopilot+ PCは、CPU・GPUとは別にNPU(Neural Processing Unit)を搭載し、ローカルでのAI処理を高速に実行できるようになりました。
なかでもWindows上で動作するOSSのLLM実行基盤Lemonade Serverは、このNPUに対応しており、手元のPCだけで現実的な推論環境を構築できます。
本記事では、「Copilot+ PCのNPUを活用して、社内PDFを参照できる“完全ローカルRAGシステム”を構築する」というテーマで、実際に構築した手順を紹介します。
今回はVisual Studio CodeのAI Toolkit、Lemonade Server、Chromaを組み合わせ、ローカルPDFの内容に基づいて回答できるRAG(Retrieval-Augmented Generation)環境を実現しました。
- システム構成
- RAGシステムの構築手順
- 文書をベクトル化して検索可能にする
- RAGサーバー
- AI Toolkit(Visual Studio Code)からRAG Serverに接続する方法
- RAGサーバーの実行
- おわりに
システム構成
本構成では、検証用に用意した就業規則PDFをChromaに取り込み、AI Toolkitからの質問に応じてRAG Serverが文脈検索を行い、その結果をもとにLemonade ServerのLLMが回答を生成する流れになっています。

最終的なディレクトリ構成イメージ
本システムを構築した際の最終的なプロジェクトフォルダ構成を示したもので、RAG Serverの動作に必要なファイルや生成されるベクトルデータの配置を視覚的に確認できます。

ハードウェア(Copilot+ PC)
ローカルAI処理の性能を重視し、NPU(Ryzen AI)を搭載した「GMKtec EVO-X1」を選択しました。
小型ながらグラフィック性能も高く、LLM推論には非常に相性が良いデバイスです。
推論サーバー(Lemonadeサーバー)
ローカルLLMの実行基盤としてLemonade Serverを利用しています。NPU推論に対応しており、Copilot+ PCの性能をフルに活かすことができます。
インストール方法については、以下の記事が参考になります。
開発環境(言語・周辺ツール)
Python 3.14
本記事で構築したRAG Server はPython 3.14で動作します。事前に以下のパッケージをインストールしておいてください。
- chromadb(ベクトルデータベース クライアント)
- sentence-transformers(エンベディングモデル:E5-small)
- fastapi(REST API サーバー)
- uvicorn(FastAPI の ASGI サーバー)
- requests(Lemonade Server API の呼び出しなどで利用)
Visual Studio Code + AI Toolkit
AI Toolkitのインストール方法については、以下の記事が参考になります。
Chroma(ベクトルデータベース)
Chromaは、エンベディングされたテキストを格納し、類似度検索によって関連文脈を即座に取り出すためのベクトルデータベースです。
RAGシステムの検索基盤として利用します。
SentenceTransformers(エンベディングモデル)
SentenceTransformersは、文章を高次元の意味ベクトルへ変換するためのライブラリです。
エンベディングを用いた類似度検索や、RAGの文脈抽出処理に利用されます。
RAGシステムの構築手順
本章では、Copilot+ PCを使って 完全ローカルで動作するRAG(Retrieval-Augmented Generation)環境を構築する手順の全体像を紹介します。
細かい実装コードは後述しますが、ここでは、どのように連携して動作するのかを工程順に理解いただけるようにまとめています。
(1) PDF をベクトル化して検索可能にする(ingest 処理)
AIが参照する対象として 事前に作成した検証用に用意した就業規則PDFをdocs/フォルダに配置します。
今回の検証では、この就業規則をローカルRAGのデータソースとして利用しました。
RAG Server(FastAPI)でingest.py を実行すると、PDFからテキストを抽出し、エンベディングモデル(E5-small)を用いて文章をベクトル化します。
ベクトル化された文書データはChromaに保存され、AI Toolkitから質問が投げられた際には、検証用に用意した就業規則の中から最も関連性の高い文章が検索される 仕組みになります。
たとえば「特別休暇について教えて?」と質問すると、就業規則PDFから該当箇所が埋め込み検索でヒットし、その内容をもとにLLMが自然な回答を生成する流れです。
(2) RAG Server(FastAPI)を構築し OpenAI互換API を提供する
Visual Studio Code側(AI Toolkit)からの問い合わせを受けるため、FastAPIによる OpenAI Chat Completions互換API /v1/chat/completionsを用意します。
RAG Serverは次の3つの役割を持ちます。
- AI Toolkitからの質問を受け取る
- Chromaで関連PDFチャンクを検索し、文脈を生成する
- Lemonade Serverへ最終プロンプトを投げ、回答を受け取る
さらに今回は、AI Toolkitがstream=Trueを必ず送ってくる仕様のため、イベントストリーミング(SSE)でAI Toolkitに返す処理 を自前で実装しています。
(3) Lemonade Server に LLM(Llama 3 等)をロードする
推論基盤としてLemonade Serverを起動し、Copilot+ PCのNPU(Ryzen AI)に最適化されたモデル(例:Llama-3.2-3B-Instruct-Hybrid)をロードします。
Lemonade ServerはWindowsネイティブで動作し、Hybridモードでは、NPU→GPU→CPUの順に最適な演算器を自動選択して高速推論 を行います。
これにより、クラウドに依存せず、PC単体でLLMが高速に動く環境が完成します。
(4)Visual Studio CodeのAI Toolkit でCustom Modelを登録する
RAG Server はOpenAI互換API を提供しているため、Visual Studio CodeのAI Toolkit → Add Custom ModelからRESTモデルとして登録できます。
登録後、Playgroundやチャット機能から以下の流れで問い合わせが行われます。
処理の全体フローは以下の通りです。
- AI Toolkit:開発者が Visual Studio Code 上で質問を入力します。
- RAG Server:AI Toolkit からのリクエストを受け取り、処理を仲介します。
- Chroma:ユーザーの質問をもとに、PDF から関連文書を検索します。
- PDF検索
- 検索結果を RAG Server に返却
- Lemonade Server:取得した文脈をもとに NPU 上で推論を実行します。
- RAG Server:生成された回答をストリーミング形式に整形します。
- AI Toolkit:ストリーミング結果を画面に表示します。
ローカルRAGがVisual Studio Codeの自然なUIで扱えるようになるため、実用途でも非常に使いやすい構成になります。
(5) Playgroundで実際に質問し、ローカルRAGが動作することを確認
最後に、AI ToolkitのPlaygroundから質問を入力すると、PDFの内容をもとにした回答がストリーミング表示で返ってきます。
たとえば「特別休暇について教えて?」と質問すると、就業規則PDFの該当箇所から文脈が検索され、Lemonade ServerのLLMが自然な文章に整形して回答します。
クラウドに一切データを送らず、完全にローカルだけでRAGが実行される点が本構成の最大の特徴です。
文書をベクトル化して検索可能にする
RAGシステムでは、まずAIが参照するPDFを検索可能なベクトルに変換する処理(ingest)を行います。
今回は、検証用に用意した就業規則PDFをdocs/フォルダに配置し、以下の手順で検索対象データを作成しました。
ingest の仕組み
ingest.pyでは、以下の3 つの処理が行われます。
- PDFのテキスト抽出
- pdfplumberを使って、就業規則PDFの本文(複数ページ)を丸ごと抽出します。
- テキストのチャンク(分割)
- LLMが扱いやすいように、500字前後の文章に分割して処理します。
- エンベディング(ベクトル化)して Chromaに保存
- SentenceTransformersのE5-smallモデルを利用し、チャンク単位で意味ベクトルに変換し、Chromaに登録します。
これにより、「特別休暇について知りたい」という質問に対して、検証用に用意した就業規則PDFの“該当箇所”を自動で取り出せるようになります。
import pdfplumber
import chromadb
from sentence_transformers import SentenceTransformer
import os
import hashlib
# ============================
# 設定
# ============================
PDF_DIR = "./docs" # PDF を置くフォルダ
DB_DIR = "./vectorstore" # Chroma の保存先
COLLECTION_NAME = "docs" # コレクション名
# ============================
# Chroma(新API)の初期化
# ============================
chroma = chromadb.PersistentClient(path=DB_DIR)
collection = chroma.get_or_create_collection(name=COLLECTION_NAME)
# ============================
# Embedding モデル
# ============================
embedder = SentenceTransformer("intfloat/multilingual-e5-small")
# ============================
# ユーティリティ関数
# ============================
def hash_text(text: str) -> str:
"""チャンク内容+ファイル名のハッシュ(重複登録を避けるため)"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def load_pdf_text(path: str) -> str:
"""PDF から全文テキストを抽出"""
text = ""
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50):
"""テキストを RAG 用にチャンク化"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start += chunk_size - overlap
return chunks
# ============================
# ingest 処理(完全版)
# ============================
def ingest():
# ===== 既存IDの読み出し =====
# include=['ids'] は Chroma の新仕様で禁止のため、
# get() 結果から ids を抽出する必要がある。
existing_ids = set()
if collection.count() > 0:
res = collection.get()
existing_ids = set(res["ids"])
print(f"[INFO] 既存ID数: {len(existing_ids)}")
# ===== docs/ フォルダ内の PDF を処理 =====
for filename in os.listdir(PDF_DIR):
if not filename.lower().endswith(".pdf"):
continue
path = os.path.join(PDF_DIR, filename)
print(f"[INGEST] {filename}")
# ---- PDF → テキスト ----
raw_text = load_pdf_text(path)
# ---- テキスト → チャンク化 ----
chunks = chunk_text(raw_text)
new_chunks = []
new_ids = []
for chunk in chunks:
chunk_hash = hash_text(filename + "_" + chunk)
# ===== 既存チャンクならスキップ =====
if chunk_hash in existing_ids:
continue
new_chunks.append(chunk)
new_ids.append(chunk_hash)
if not new_chunks:
print(f"[SKIP] 新規チャンクなし: {filename}")
continue
# ---- Embedding ----
embeddings = embedder.encode(new_chunks)
# ---- Chroma に登録 ----
collection.add(
documents=new_chunks,
embeddings=embeddings,
ids=new_ids
)
print(f"[ADD] {len(new_chunks)} チャンク追加: {filename}")
print("[DONE] Ingest completed.")
# ============================
# 実行エントリーポイント
# ============================
if __name__ == "__main__":
ingest()
ingest.py の実行手順
ローカルRAG環境で利用する PDFをベクトル化するために、まずingest.pyを実行します。
この処理では、PDFの読み取り→テキスト抽出→チャンク分割→エンベディング → Chroma保存までを一括で行います。

RAGサーバー
本システムはFastAPIを中心に、SentenceTransformer・Chroma・Lemonade Serverを組み合わせて構築したシンプルで軽量なローカルRAGサーバーです。
AI Toolkitから届いたメッセージをベクトル化し、Chromaで関連文書を検索してコンテキストとして付与し、その情報をLemonade ServerのLLMに渡して回答を生成します。
処理の全体フローは以下の通りです。
- FastAPIがOpenAI互換形式(ChatCompletion)のJSONを受け取る
- messagesの中からrole=userの質問だけを抽出
- SentenceTransformerで質問を埋め込み化し、Chromaで関連チャンクを検索
- ユーザー質問 + 文脈 をつなげて RAG用プロンプトを生成
- Lemonade ServerのLLMにプロンプトを投げて回答を取得
- AI Toolkitがstream=Trueのため、回答をSSE(イベントストリーム)で小分け返却
本システムは「検索 → 文脈付与 → LLM推論 → ストリーミング返却」までを1つのエンドポイントで完結しており、ローカルの NPU でも高速に動作する軽量RAG構成になっています。
コード全体は約200行あるため、以下では特に理解に役立つ主要ロジック部分のみを抜粋して紹介します。
ユーザーメッセージ抽出
AI Toolkitから送られてくるChatCompletionのmessagesは複数形式を取り得るため、安全にrole=userのコンテンツだけ取り出す関数を作っています。
- string
- list
- dict
def get_user_message(messages):
for m in messages:
if m.get("role") == "user":
return extract_content(m.get("content"))
raise ValueError("No user message found.")
RAGクエリ(ベクトル検索)
ユーザー質問を埋め込みに変換し、Chromaから最も関連性が高い文脈を取得します。
qvec = embedder.encode([user_message])[0]
result = collection.query(query_embeddings=[qvec], n_results=3)
contexts = result["documents"][0] if result["documents"] else []
context_text = "\n".join(contexts)
SSEストリーミング返却(OpenAI互換)
AI Toolkitは必ずstream=Trueを送信するため、OpenAI APIと同じevent-stream形式で回答を分割して返却します。
def stream_response(answer_text, model_name):
chunk_size = 30
for i in range(0, len(answer_text), chunk_size):
chunk = answer_text[i:i+chunk_size]
data = {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion.chunk",
"model": model_name,
"choices": [{
"index": 0,
"delta": {"content": chunk},
"finish_reason": None
}]
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
time.sleep(0.02)
yield "data: [DONE]\n\n"
ChatCompletion API(/v1/chat/completions)の処理フロー
エンドポイント本体は100行以上あるためブログ全文掲載は避けますが、内部では以下の順に処理が行われます。
- POSTでJSONを受信
- ユーザーメッセージを抽出
- RAG(埋め込み→ベクトル検索→文書取得)を実行
- 文脈+質問でプロンプトを生成
- Lemonade Serverに問い合わせ
- stream=True→SSEストリーミング返却
- stream=False→通常JSON返却
- エラー時は 500を返却しログへ出力
server.py の実行
server.pyを起動すると、FastAPIがローカルでRAGサーバーとして立ち上がり、以下のように「Uvicorn running on http://127.0.0.1:8000」などの起動ログが表示され、エンドポイント /v1/chat/completions が正常に待ち受け状態になっていることを確認できます。

AI Toolkit(Visual Studio Code)からRAG Serverに接続する方法
RAG ServerはOpenAI互換 API (/v1/chat/completions) として動作するため、Visual Studio CodeのAI Toolkit→Custom Modelから簡単に接続できます。
操作手順の全体像については以下の記事が参考になります。
Custom Model の追加
Visual Studio Codeの左側メニューからAI Toolkitを開き、Models→Add Model→「Add OpenAI Compatible Model」を選択します。
接続設定
|
設定項目 |
入力値 |
|
Endpoint |
|
|
Model |
Llama-3.2-3B-Instruct-Hybrid (Lemonade にロードしているモデル名) |
|
Model Name |
Local RAG Server(任意) |
|
API Key |
local-rag(ダミーでOK) |
RAGサーバーの実行
「Playground」をクリックすると、プロンプト入力画面が表示されます。

ユーザーが質問を入力すると、ローカルの RAG Server が社内PDFを検索し、その結果をもとに内部モデル(Lemonade Server)で推論を行った回答が表示されます。

ハイブリッドモデルによりNPUとGPUがバランスよく利用されています。

おわりに
本記事では、Copilot+ PCのNPUを活用し、完全ローカルで動作するRAGシステムを構築する方法をご紹介しました。
クラウドにアップロードできない社内資料を、安全にローカル環境だけで参照できる点は大きなメリットです。
今後は、このローカルRAG環境を コンテナ化して用途ごとに切り替えられる構成や、画像・ファイルを扱える マルチモーダルRAGへの拡張、さらにはNPU最適化モデルの検証などにも取り組んでいきたいと考えています。
ここまで読んでいただき、ありがとうございました。この記事が、ローカル生成AI活用を検討されている方の参考になれば幸いです。
神田 英樹(日本ビジネスシステムズ株式会社)
西日本事業本部 クラウドサービス2部3グループに所属しています。主にAzureなどのクラウドサービスを取り扱っています。趣味は特にありませんが、愛犬のお世話をして過ごすことが多いです。
担当記事一覧