書籍『LangChainとLangGraphによるRAG・AIエージェント』をLM Studioを使ってローカルで進める

『LangChainとLangGraphによるRAG・AIエージェント』という本を読み進めていました。 この本ではOpenAIのAPIやLangSmithを使うのが標準になっているのですが、APIの課金を気にせずローカル環境で試行錯誤したかったので、LM Studioを利用してどこまでいけるかやってみました、という日記です。

今回は、1章〜4章(LLMの基本、Vision、Function Calling、RAG)までの内容をローカルLLMに置き換えて実装したメモ。

動かしたPCは、MacBook Air M2です。

LM Studioの準備

LM Studioをインストール。LLMは gpt-oss-20b をダウンロード。LM StudioでローカルにAPIサーバーを立てる。

このようにGUIでモデルをダウンロードしてサーバーを起動するだけで、OpenAI互換のWeb APIが使える。

curl でテストしてみる。

$ curl -X POST http://127.0.0.1:1234/v1/chat/completions \
     -H "Content-Type: application/json" \
     -d '{
           "model": "openai/gpt-oss-20b",
           "messages": [
               {"role": "system", "content": "You are a helpful assistant."},
               {"role":"user","content":"こんにちは!"}
           ],
           "max_tokens": 200
         }'
{
  "id": "chatcmpl-hjvrqbovlqfrylx9x35ee",
  "object": "chat.completion",
  "created": 1784507745,
  "model": "openai/gpt-oss-20b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは!今日はどんなことに興味がありますか?お気軽にお話しくださいね。",
        "reasoning": "Need to respond in Japanese friendly greeting.",
        "tool_calls": []
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 69,
    "completion_tokens": 39,
    "total_tokens": 108,
    "completion_tokens_details": {
      "reasoning_tokens": 8
    }
  },
  "stats": {},
  "system_fingerprint": "openai/gpt-oss-20b"
}

ちゃんとレスポンスが返ってきた!

書籍の 『2.5 Chat Completions API』の最初のPythonのコードを試す。書籍では Google Colab を使ってるが、ローカルで uv を使って動かすことにする。

$ uv init sample_proj
$ uv add openai==1.40.6

main.pyに実装する。違いは OpenIAI コンストラクタに base_urlapi_key をセットするだけ。 api_key はLM Studioで認証なしにしている場合は適当な文字列を指定すればいい。

from openai import OpenAI

client = OpenAI(
  base_url = "http://localhost:1234/v1",
  api_key = "dummy",
)
response = client.chat.completions.create(
    model = "openai/gpt-oss-20b",
    messages = [
        {"role": "system", "content": "You are a helpful assistat."},
        {"role": "user", "content": "こんにちは!私はジョンと言います!"},
    ],
)
print(response.to_json(indent=2))

実行!

$ uv run main.py
{
  "id": "chatcmpl-c6zo9zzwrmbgy8mnvxzh7",
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "logprobs": null,
      "message": {
        "content": "こんにちは、ジョンさん!  \n私はChatGPTです。どんなご質問やお手伝いが必要でしょうか?お気軽にどうぞ!",
        "role": "assistant",
        "tool_calls": [],
        "reasoning": "User greeting in Japanese. Should respond politely, introduce assistant."
      }
    }
  ],
  "created": 1784509758,
  "model": "openai/gpt-oss-20b",
  "object": "chat.completion",
  "system_fingerprint": "openai/gpt-oss-20b",
  "usage": {
    "completion_tokens": 54,
    "prompt_tokens": 89,
    "total_tokens": 143,
    "completion_tokens_details": {
      "reasoning_tokens": 12
    }
  },
  "stats": {}
}

うむ、動いてる。

Visionを使った画像解析

次は画像解析。さっきは gpt-oss-20b を使っていたけれど、このモデルは画像をインプットすることができない。LM StudioでロードするモデルのCapabilitiesに「Vision」があるかどうかで判別できる。

gpt-oss-20b は以下のページでも確認できて、Capabilitiesに「Vision」はない。

Modell Catalog ページgemma-4-26b-a4b-qat が「Vision」に対応していたので、使ってみることにする。

LM Studioで、 gemma-4-26b-a4b-qat をダウンロードして、 Local Server から gpt-oss-20b をEjectしてから gemma-4-26b-a4b-qat をLoadする。

最近ふらっと長野に行ったときに食べたラーメンの写真を解析させてみよう。

messages で画像のURLを指定する。

image_url = "https://i.gyazo.com/90235c2479880102647427002ddedb40.jpg"
response = client.chat.completions.create(
    model = "openai/gpt-oss-20b",
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "画像を説明してください"},
                {"type": "image_url", "image_url": {"url": image_url}},
            ],
        },
    ],
)

しかし、これではダメ。以下の400エラーが返ってきた。

Openai.BadRequestError: Error code: 400 - {'error': "'url' field must be a base64 encoded image."}

エラーメッセージの通り、画像をBASE64文字列に変換してからパラメータに指定してみる。 $ uv add requests してからmain.pyを書き換える。

import base64
import requests
image_url = "https://i.gyazo.com/90235c2479880102647427002ddedb40.jpg"
image_base64 = base64.b64encode(requests.get(image_url).content).decode("utf-8")
response = client.chat.completions.create(
    model = "google/gemma-4-26b-a4b-qat",
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "画像を説明してください"},
                {"type": "image_url", "image_url": {"url": f"data:image/jpg;base64,{image_base64}"}},
            ],
        },
    ],
)
print(response.choices[0].message.content)

実行結果はこんな感じ。

% uv run main.py
白いボウルに入ったラーメンのクローズアップ写真です。

ラーメンの詳細は以下の通りです:
* **スープ:** 濃厚そうな白濁したスープ。                                                                                                                                   * **トッピング:** もやし、キャベツ、そしてピンクと白の渦巻き模様が特徴的な「なると」が乗っています。
* **器:** 白い陶器のボウルで、縁には日本語で「みんなのラーメン」という文字と「WELCOME」という英語のロゴが入っています。ボウルは赤い皿の上に置かれています。
* **スプーン:** 黒いプラスチック製のレンゲがボウルの中に添えられています。

背景はぼやけていますが、木製のテーブルの上にあり、調味料入れやコップなどが写っています。全体的に、明るいレストランでの食事の風景です。

プロンプトを変えて 画像を説明してください ではなく 食材の一覧を `['A', 'B', ...]` という形式で出力してください に変更してみる。ちゃんとパース可能な配列形式で出力された。なかなか良い。

% uv run main.py
['麺', 'スープ', 'キャベツ', 'もやし', 'なると', 'にんじん', 'きのこ']

Vision を使うと重くなる

gemma-4-26b-a4b-qat のVisionを使うとpcが一時的に重くなる。 gemma-4-12b-qat にすると重くなることは無くなるけど処理時間が長くなる。モデル名に含まれている a4b は Active 4 Billiton の略で、モデルのパラメータは26b (260億 × パラメータ1つのサイズ4bit = 13GB) なんだけど実行時は 4b (4億 × パラメータ1つのサイズ4bit = 2GB) で済む、ということみたい。

しかし、Visionを使う場合はどうも重くなる、確認するとメモリが不足していてスワップが発生している。Visionを使うと4bで収まってなくてメモリが足りないということかなぁと思いながら、AIさんに聞きながら調べてみた。a4bではMoE(Mixture of Experts)という構造になっていてVisionを使う場合は別のエンコーダーを動かしているらしく、これでVRAMをたくさん使うのかな、と想像したけど腑に落ちるところまで辿り着けず。詳しく知りたい時はちゃんと調べるということで。。

他に gemma-4-e4b というモデルもある。e4bだと軽いし回答も速い。推論の精度は弱くなるんだと思う。

gemma-4-e4b での実行結果。

これは、具沢山の温かそうなスープがたっぷり入ったボウルのクローズアップ写真です。

**【詳細な説明】**

*   **料理の全体像:** 明るいベージュ色の、クリーミーで優しい色合いのスープが特徴的です。熱々でホッとできるような、食欲をそそる一皿に見えます。
*   **具材:** 様々な野菜が豊富に使われています。
    *   中央には、大きな鮮やかな緑色のレタス(またはキャベツ)の葉が配置されています。
    *   白くて細長い野菜(豆苗や春雨のようなもの)がたっぷりと入っており、ボリューム感があります。
    *   オレンジ色の細切りにされたニンジンが彩りを添えています。
    *   スープの中には、薄いキノコやその他の具材が確認できます。
    *   特に目を引くのは、中央のレタスの上に置かれた、淡いピンク色の花のような形の飾り(おそらく、装飾的な野菜や竹の子など)です。
*   **盛り付け:** 料理はシンプルな白いボウルに盛られており、そのボウルが鮮やかな赤色の皿(または受け皿)の上に置かれています。
*   **雰囲気:** 背景はややぼやけていますが、木製のテーブルの上で撮影されており、カフェやレストランでの食事の様子を捉えています。ボウルの縁には「みんなの WEL COME」といった
文字が見えます。

全体として、栄養バランスが良く、見た目も華やかな、心が温まるスープ料理です。

食材の方。

`['クリーミーなスープ', 'ガラス麺(ビーフン)', 'キャベツ', 'ニンジン', 'ハムまたは肉の切れ端', 'キノコ類(または暗色の具材)', 'エディブルフラワー']`

うーん、確かに微妙かも?

Function Calling

特に問題なく書籍のコードがLM Studioでも動いた。

RAG (Retrieval-Augmented Generation)

次はRAG。書籍では OpenAI の Embedding API を使ってテキストをベクトル化しているが、LM Studioでやる。LM Studioのサーバーに推論のモデルとは別に追加で Embedding 用のモデルをLoadする。LM Studioに最初から用意されている?っぽい text-embedding-omic-embed-text-v1.5 をLoadする。

↓こんな感じで2つのモデルがロードされている状態。

Vector storeには書籍の通りChromaを利用する。

必要なライブラリを追加。

$ uv add langchain-core==0.3.0 langchain-openai==0.2.0 langchain-community==0.3.0 GitPython==3.1.43 langchain-text-splitters==0.3.0 langchain-chroma==0.1.4

諸々のライブラリはバージョンがもっと新しくなってる。書籍で指定されているバージョンだと以下のエラーが出た。

書籍のバージョンで動かすことを優先するため、 httpx をバージョンを下げる。

$ uv add "httpx<0.28.0"

書籍の「4.6 LangChain の RAG に関するコンポーネント」を参考にソースコードを書く。

# Document loaderでドキュメントの読み込み
# このサンプルでは、Gitリポジトリからmdファイルを読み込む
from langchain_community.document_loaders import GitLoader

def file_filter(file_path: str) -> bool:
    return file_path.endswith(".md")

loader = GitLoader(
    clone_url="https://github.com/langchain-ai/langchain",
    repo_path="./langchain",
    branch="master",
    file_filter=file_filter,
)
raw_docs = loader.load()
print(f"len(raw_docs) = {len(raw_docs)}")


# Document transformerで何らかの変換
# このサンプルでは、1000文字で分割する
from langchain_text_splitters import CharacterTextSplitter

text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
docs = text_splitter.split_documents(raw_docs)
print(len(docs))
print(f"len(docs) = {len(docs)}")

# LM Studio の Embedding model を利用
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(
    base_url="http://localhost:1234/v1",
    api_key = "dummy",
    model="text-embedding-nomic-embed-text-v1.5", # Embeddingに対応したモデル
    check_embedding_ctx_length=False,
)


# Vector store (Chrome) にドキュメントをベクトル化して保存

from langchain_chroma import Chroma

db = Chroma.from_documents(docs, embeddings)


# retriever を使ってドキュメントを取得
retriever = db.as_retriever()
query = "perplexityからデータを読み込むためのDocument loaderはありますか?"
context_docs = retriever.invoke(query)
first_doc = context_docs[0]
print(f"len(context_docs) = {len(context_docs)}")
print(f"metadata = {first_doc.metadata}")
print(first_doc.page_content)

実行する。

% uv run rag.py

len(raw_docs) = 29
Created a chunk of size 1037, which is longer than the specified 1000
Created a chunk of size 1037, which is longer than the specified 1000
Created a chunk of size 1455, which is longer than the specified 1000
len(docs) = 135
Failed to send telemetry event ClientStartEvent: capture() takes 1 positional argument but 3 were given
Failed to send telemetry event ClientCreateCollectionEvent: capture() takes 1 positional argument but 3 were given
Failed to send telemetry event CollectionQueryEvent: capture() takes 1 positional argument but 3 were given
len(context_docs) = 4
metadata = {'file_name': 'README.md', 'file_path': 'libs/partners/perplexity/README.md', 'file_type': '.md', 'source': 'libs/partners/perplexity/README.md'}
# langchain-perplexity

[![PyPI - Version](https://img.shields.io/pypi/v/langchain-perplexity?label=%20)](https://pypi.org/project/langchain-perplexity/#history)
[![PyPI - License](https://img.shields.io/pypi/l/langchain-perplexity)](https://opensource.org/licenses/MIT)
[![PyPI - Downloads](https://img.shields.io/pepy/dt/langchain-perplexity)](https://pypistats.org/packages/langchain-perplexity)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)

Looking for the JS/TS version? Check out [LangChain.js](https://github.com/langchain-ai/langchainjs).

## Quick Install

```bash
uv add langchain-perplexity
```

## 🤔 What is this?

This package contains the LangChain integration with Perplexity.

## 📖 Documentation

Chromaから該当のドキュメントがちゃんとヒットした!

おしまい

ここまでで、ローカルLLMを使ったRAGの構築まではうまくいきました!

次は「プロンプトを変えたり、検索件数を変えたときに、本当に精度が良くなっているのか?」をどう評価すればいいのか。書籍ではここでLangSmithを利用しますが、次はこの部分をOSSのLangfuseを使ってローカルで試していこうと思います。