書籍『LangChainとLangGraphによるRAG・AIエージェント』をローカルで進める その3:LangfuseのEvaluation

前回の記事で、Langfuseを使ってローカルでのPrompts (プロンプトの管理に関する機能)とTracing (トレースの収集に関する機能)を試してみました。

今回はEvaluation (評価に関する機能)です。書籍の7章のメインテーマである「RAGの評価(Evaluation)」に該当します。書籍ではLangSmithとRAG評価フレームワーク「Ragas」を組み合わせています。前回に引き続き、LangSmithの代わりにLangfuseを使うことでローカル&無料で試してみます。

全体の流れ

RAGを使った処理の評価には、オフライン評価とオンライン評価がありますが、オフライン評価をやる。

全体の流れとしては以下の通り。

    1. Ragasを使ってデータセットを作成しLangfuseに登録する
    1. 評価対象の処理を実行する
    2. このとき、上記のデータセットを利用する
    3. 処理に加えて評価が行われ、評価結果がLangfuseに登録される
    1. Langfuseの画面から評価結果を確認する

1. Ragasを使ってデータセットを作成しLangfuseに登録する

RAGを評価するためには「問題」と「模範解答」のデータセット(Dataset)が必要。手で作るのは大変なので、Ragasの合成データ生成機能を利用する。LLMなどを使って生成したデータを合成データと呼ぶみたい。

LangChainリポジトリのドキュメントを元にRagasを使って合成データを生成し、Langfuseの Datasets に保存する。

では実装へ。

前々回、前回の流れの続きで、追加で $ uv add ragas==0.1.14 nest-asyncio==1.6.0 する。

ソースコードは以下の通り。以下の2点が書籍と異なる。

  • modelがLM StudioでLoadしているものになっていること
  • データセットの登録先がLangfuseになっていること
# create_datasets.py
# --------------------

# RAGの検索対象は引き続きLangChainの公式ドキュメントを利用する
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,
)
documents = loader.load()
print(f"len(documents) = {len(documents)}")



# Ragasで合成テストデータ生成
import os
import nest_asyncio
from ragas.run_config import RunConfig
from ragas.testset.generator import TestsetGenerator
from ragas.testset.evolutions import simple, reasoning, multi_context
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

for document in documents:
    document.metadata["filename"] = document.metadata["source"]

nest_asyncio.apply()

api_key = "dummy"
model = ChatOpenAI(
    base_url = "http://localhost:1234/v1",
    api_key = api_key,
    temperature = 0,
)
embeddings = OpenAIEmbeddings(
    base_url="http://localhost:1234/v1",
    api_key = api_key,
    model="text-embedding-nomic-embed-text-v1.5", # Embeddingに対応したモデル
    check_embedding_ctx_length=False,
)
generator = TestsetGenerator.from_langchain(
    generator_llm = model,
    critic_llm = model,
    embeddings = embeddings,
)
testset = generator.generate_with_langchain_docs(
    documents,
    test_size = 4, # 生成するデータを4つ
    distributions = {
        simple: 0.5, # 生成するデータの50%, つまり2つは単純
        reasoning: 0.25, # 1つは回答に推論が必要
        multi_context: 0.25, # 1つは回答に複数の情報源が必要
    },
)

print(testset.to_pandas())



# LangfuseのDatasetsに合成テストデータを保存
from langfuse import Langfuse
os.environ["LANGFUSE_SECRET_KEY"] = "sk-..." # langfuseから取得
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-..." # langfuseから取得
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = Langfuse()
dataset_name = "langchain docs"
langfuse.create_dataset(
    name=dataset_name,
    description="My first dataset",
    metadata={
        "author": "kariyayo",
        "date": "2026-07-01",
        "type": "benchmark"
    }
)
for testset_record in testset.test_data:
    langfuse.create_dataset_item(
        dataset_name = dataset_name,
        input = {"question": testset_record.question},
        expected_output = {"contexts": testset_record.contexts, "ground_truth": testset_record.ground_truth},
        metadata = {"source": testset_record.metadata[0]["source"], "evolution_type": testset_record.evolution_type},
    )

いざ実行。前回、前々回の続きなので、LM StudioのサーバーとLangfuseは起動している状態。

すると以下のメッセージが。そして最終的には例外を投げてエラー終了。

LangChainDeprecationWarning: As of langchain-core 0.3.0, LangChain uses pydantic v2 internally. The langchain_core.pydantic_v1 module was a compatibility shim for pydantic v1, and should no longer be used. Please update the code to import from Pydantic directly.

For example, replace imports like: `from langchain_core.pydantic_v1 import BaseModel`
with: `from pydantic import BaseModel`
or the v1 compatibility namespace if you are working in a code base that has not been fully upgraded to pydantic 2 yet.         from pydantic.v1 import BaseModel

  from ragas.metrics._answer_correctness import AnswerCorrectness, answer_correctness

ここまではLangChain v0.3系で進めてきた。書籍にも注釈として書いてあるのだけど、当時Ragasのバージョンは0.1系でLangChain v0.3に対応していなかったらしく、書籍ではRagasを使うところはLangChain v0.2系で進めている。

今ではRagas v0.2以降がリリースされていて、LangChain v0.3系に対応している。しかし、Ragas v0.2で破壊的変更があったようで、書籍の内容を読み進めることが難しくなる。なので、書籍の通りLangChainのバージョンを0.2系に下げて進めることにする。

$ uv add langchain-core==0.2.30 langchain-openai==0.1.21 langchain-community==0.2.12 "langchain-text-splitters<0.3.0" langchain-chroma==0.1.2 chromadb==0.5.3 ragas==0.1.14 nest-asyncio==1.6.0

これで実行できた!けど、すごく時間が掛かる。。PCのスペック、キツい。。

これが、、

% uv run regas_langfuse.py
len(documents) = 29
embedding nodes:  85%|█████████████████████████████████████████████████████████████████████████████████████████████████▌                 | 73/86 [15:19<05:18, 24.47s/it]

こうなって、、

% uv run regas_langfuse.py
len(documents) = 29
Generating:   0%|                                                                                                                                  | 0/4 [00:00<?, ?it/s]

こう!

% uv run regas_langfuse.py
len(documents) = 29
Generating: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 4/4 [19:25<00:00, 291.43s/it]
                                            question                                           contexts  ...                                           metadata episode_done
0  What are legacy chains in the LangChain ecosys...  [# 🦜️🔗 LangChain Classic\n\n[![PyPI - Version]...  ...  [{'source': 'libs/langchain/README.md', 'file_...         True
1  What is the purpose of the LangChain integrati...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...  [{'source': 'libs/partners/openrouter/README.m...         True
2  How does LangChain‑OpenRouter let you tap mult...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...  [{'source': 'libs/partners/openrouter/README.m...         True
3  How can devs contribute to LangChain while cho...  [# 🦜️🔗 LangChain Classic\n\n[![PyPI - Version]...  ...  [{'source': 'libs/langchain/README.md', 'file_...         True

[4 rows x 6 columns]

左メニューのDatasetsを選ぶと、登録されていることが分かる。

登録されている行をクリックするとこんな感じの画面。

データセットとは?

Langfuseのドキュメントにデータ構造の図があります。

Experiments Data Model - Langfuse

  • 評価のための処理を実行すると、DatasetRunができる
  • DatasetRunはn個のDatasetRunItemを持つ
  • DatasetRunItemは、1つのDataSetItemと関連し、1つのTraceと関連する

この関係が重要。このページを読まずになんとなく評価実行のソースコードを書いても上手くいかず。。では、次は評価実行のソースコードへ。

2. 評価対象の処理を実行する

データセットの登録ができたので、評価のステップに進む。

まずは評価対象のコードを用意。

# evaluation_langfuse.py
# ------------------------


# =====================================================================
# まずは評価対象となるRAGを使ったアプリケーションの処理
# =====================================================================

# RAGの検索対象は引き続きLangChainの公式ドキュメントを利用する
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,
)
documents = loader.load()
print(len(documents))



# RAG を用意
import os
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
api_key = "dummy"
model = ChatOpenAI(
    base_url = "http://localhost:1234/v1",
    api_key = api_key,
    temperature = 0.0,
    timeout = 600,
    max_retries = 1,
)
embeddings = OpenAIEmbeddings(
    base_url="http://localhost:1234/v1",
    api_key = api_key,
    model="text-embedding-nomic-embed-text-v1.5", # Embeddingに対応したモデル
    check_embedding_ctx_length=False,
)
db = Chroma.from_documents(documents, embeddings)


# 評価対象となるRAGを使ったChain
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel, RunnablePassthrough

prompt = ChatPromptTemplate.from_template('''\
以下の文脈だけを踏まえて質問に回答してください。

文脈:"""
{context}
"""

質問:{question}
''')

retriever = db.as_retriever()

chain = RunnableParallel(
    {
        "question": RunnablePassthrough(),
        "context": retriever,
    }
).assign(answer=prompt | model | StrOutputParser())

ここまでは特に新しいことはなし。

続けて実行と評価の処理のソースコード。ここは先述したDataSetsのデータ構造を把握する前に書き始めたらなかなか上手くいかなかった部分。

# evaluation_langfuse.py の続き
# ------------------------


# =====================================================================
# ここから評価対象のChainを実行しつつ評価結果をLangfuseに登録する処理
# =====================================================================

# 1. Ragasの設定
from ragas.llms import LangchainLLMWrapper
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.metrics import answer_relevancy, context_precision

ragas_llm = LangchainLLMWrapper(langchain_llm=model)
ragas_embeddings = LangchainEmbeddingsWrapper(embeddings)
context_precision.llm = ragas_llm
context_precision.embeddings = ragas_embeddings
answer_relevancy.llm = ragas_llm
answer_relevancy.embeddings = ragas_embeddings



# 2. 初期化とDatasets(テスト問題集)のロード
from langfuse import Langfuse
os.environ["LANGFUSE_SECRET_KEY"] = "sk-..." # langfuseから取得
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-..." # langfuseから取得
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = Langfuse()
DATASET_NAME = "langchain-docs" # Langfuseの画面で確認したデータセットの名前を指定
langfuse_dataset = langfuse.get_dataset(DATASET_NAME)



# 3. テストの実施(RAGアプリケーションの実行)
#    テスト問題集から1問ずつRAGアプリに入力し、解答を集める
from datasets import Dataset
from ragas import evaluate
from langfuse.langchain import CallbackHandler

evaluation_data = {
    "question": [],     # ユーザーからの質問
    "contexts": [],     # RAGでChromaから検索した結果
    "answer": [],       # RAGが最終生成した回答
    "ground_truth": [], # テストデータセットにある模範回答
}
traces_to_link = []
trace_ids = []
print("評価を開始します。RAGアプリケーションに問題を解かせています...")

RUN_NAME = "LM-Studio-RAG-Evaluation"  # Langfuseの画面に表示される実験名。データ構造のDatasetRun.nameに該当してるはず

for item in langfuse_dataset.items:
    question = item.input["question"]
    ground_truth = item.expected_output.get("ground_truth", "")
    langfuse_handler = CallbackHandler()
    # 評価対象のChainを実行
    response = chain.invoke(
        question,
        config={"callbacks": [langfuse_handler]},
    )
    answer = response["answer"]
    raw_context = response["context"]
    contexts = []
    for doc in raw_context:
        if hasattr(doc, "page_content"):
            contexts.append(doc.page_content)
        elif isinstance(doc, dict):
            contexts.append(doc.get("page_content", str(doc)))
        else:
            contexts.append(str(doc))
    
    if "ground_truths" not in evaluation_data:
        evaluation_data["ground_truths"] = []
    
    evaluation_data["question"].append(str(question))
    evaluation_data["contexts"].append(contexts)
    evaluation_data["answer"].append(str(answer))
    evaluation_data["ground_truth"].append(str(ground_truth))
    evaluation_data["ground_truths"].append([str(ground_truth)])
    trace_ids.append(langfuse_handler.last_trace_id)
    traces_to_link.append(item)

ragas_dataset = Dataset.from_dict(evaluation_data)



# 4. Ragasによる自動採点
print("スコア計算を開始します...")
from ragas.run_config import RunConfig
result = evaluate(
    dataset=ragas_dataset,
    metrics=[context_precision, answer_relevancy],
    run_config=RunConfig(max_workers=1, max_retries=3)
)
df = result.to_pandas()
print("\n=== 採点結果のプレビュー ===")
print(df)



# 5. Langfuseへの書き込み
print("\n 結果をLangfuseの Dataset Run に記録しています...")

def safe_score(val):
    import math
    return 0.0 if (val is None or (isinstance(val, float) and math.isnan(val))) else float(val)

for i, item in enumerate(traces_to_link):
    row = df.iloc[i] # この問題の採点結果の行を取得
    actual_trace_id = trace_ids[i]
    
    # トレースをDataset ItemおよびDataset Runに紐付ける
    try:
        langfuse.api.dataset_run_items.create(
            run_name=RUN_NAME,
            dataset_item_id=item.id,
            trace_id=actual_trace_id
        )
    except Exception as e:
        print(f"Dataset Run Itemへの紐付けに失敗しました (item_id: {item.id}): {e}")
    
    # 算出された点数を、項目ごとにLangfuseに送信する(0.0〜1.0の数値)
    if "answer_relevancy" in row:
        langfuse.create_score(
            name="answer_relevancy",
            value=safe_score(row["answer_relevancy"]),
            trace_id=actual_trace_id
        )

    if "context_precision" in row:
        langfuse.create_score(
            name="context_precision",
            value=safe_score(row["context_precision"]),
            trace_id=actual_trace_id
        )

print("評価が完了しました!")

いざ、実行!

$ uv run evaluation_langfuse.py
29
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
評価を開始します。RAGアプリケーションに問題を解かせています...                                                                                                             Failed to send telemetry event CollectionQueryEvent: capture() takes 1 positional argument but 3 were given
スコア計算を開始します...
Evaluating: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 8/8 [11:32<00:00, 86.53s/it]

=== 採点結果のプレビュー ===
                                            question                                           contexts  ... context_precision answer_relevancy
0  How can devs contribute to LangChain while cho...  [# 🦜️🔗 LangChain Classic\n\n[![PyPI - Version]...  ...               0.0         0.808152
1  How does LangChain‑OpenRouter let you tap mult...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...               1.0         0.917410
2  What is the purpose of the LangChain integrati...  [# langchain-openrouter\n\n[![PyPI - Version](...  ...               1.0         0.948827
3  What are legacy chains in the LangChain ecosys...  [# 🦜🍎️ LangChain Core\n\n[![PyPI - Version](ht...  ...               0.0         0.956593

[4 rows x 7 columns]

 結果をLangfuseの Dataset Run に記録しています...
評価が完了しました!

3. Langfuseの画面から評価結果を確認する

LangfuseのDatasets画面を見ると、「Experiments」列の値が 1 になっている。

データセットを選択して「Experiments」タブを選択する。

この画面に表示される行が、データ構造上のDatasetRunに対応している。

DatasetRunをクリック。DatasetRunItemの一覧画面へ。

「Trace」列のアイコンをクリック。Traceが見れる。ベクトル検索の結果、4つのmarkdownファイルの内容が返ってきてる。

それで、以下のプロンプトをOpenAI APIに投げてることが分かる。

以下の文脈だけを踏まえて質問に回答してください。

文脈:"""
... <- ここにベクトル検索結果の4つのmarkdownファイルの中身
"""

質問:What are legacy chains in the LangChain ecosystem, and why should most users prefer using the main langchain package instead?

OpenAI APIからのレスポンスも確認できる。レスポンスにはしっかりと説明が書かれている。

だけど、データセットの期待値としては、「回答できない」という内容である。したがって、

  • answer_relevancy: 0.96
    • LLMからのレスポンスがしっかり書かれている
  • context_precision: 0.00
    • 「以下の文脈だけを踏まえて」とプロンプトに書いたのに、データセットのExpected outputのContextからは得られないような情報まで出力されている

ということかな。そして、レスポンスの内容がハルシネーションではないと人間が見て判断できたなら、データセットが間違っていると。

おしまい

『LangChainとLangGraphによるRAG・AIエージェント』と、ローカルで動かすための試行錯誤のおかげで、知らない世界へ一歩踏み込めた気がします!

書籍『LangChainとLangGraphによるRAG・AIエージェント』をローカルで進める その2:LangfuseのPromptsとTracing

前回の記事で、LM Studioを利用してローカルでRAGを試してみました。

今回は、書籍でLangSmithが使われている部分について、ここもローカルで済ませるためにオープンソースのLangfuseとRAG評価フレームワークのRagasを組み合わせてみます。

LangSmithについて「書籍:LangChainとLangGraphによるRAG・AIエージェント 7.2 LangSmithの概要」には以下のように書いてあります。

LangSmithは大きく次の3種類の機能を持ちます。

1. Tracing (トレースの収集に関する機能)
2. Prompts (プロンプトの管理に関する機能)
3. Evaluation (評価に関する機能)

この3つの機能をLangfuseでやってみます。

終わってしまえば難しいところはないのですが、ちょいちょいハマったところがあったので、「⚠️」マークでメモを残しておきます。

Langfuseの準備

Langfuseのリポジトリからcloneして、docker-composeで起動するだけ。

$ docker compose up

localhost:3000にアクセス。sign upから適当なメールアドレスとパスワードでアカウントを作る。

「組織」作成後、設定画面でAPIキーを作る。

ここまででLangfuseの準備は完了!

Prompts(プロンプトの管理に関する機能)

LangfuseのPrompts機能を使ってみる。書籍の「4.3 Prompt template」に相当する。

この機能を使うと、LLMを使うアプリケーションのソースコードとは別にプロンプトの共有やバージョン管理ができる。

プロンプトをLangfuseに登録する

Langfuseの画面からプロンプトを作成する。作成後は以下のような画面。左メニューの「Prompts」を選択し、画面右上の「+ New prompt」から作成する。

作成画面で TextChat かを選択するところで Chat を選択する。そうすると以下のような入力フォームになる。システムプロンプトやユーザーのプロンプトを書ける。ここでは以下のように入力する。

  • system : ユーザーが入力した料理のレシピを考えてください。
  • user : {{dish}}

⚠️ ユーザープロンプトには {{dish}} とだけ書いてある。 {{ }} というの変数のプレースホルダーを表す。書籍で利用している LangSmith では { } がプレースホルダーらしい。最初は {dish} と書いていてうまく動かずハマった。LangSmithとLangfuseのライブラリの違いかな。

これで作成する。作成後、一覧画面からプロンプトを選択すると以下のような詳細画面になる。ここから編集ができるが、正確には編集ではなく新しいバージョンとして登録される。↓の画面では3つバージョンがある状態。

Langfuseに登録したプロンプトを使ってみよう

じゃあソースコードからこのプロンプトを使ってみる。

ライブラリを追加。

$ uv add langfuse==4.14.0

ソースコードは以下の通り。

⚠️ 書籍のLangSmithを使った例とほぼ同じなのだけど、 prompt.compile(dish="カレー") の部分が書籍では prompt.invoke({"dish": "カレー"}) と微妙に異なっていてハマった。

import os
from langfuse import Langfuse

os.environ["LANGFUSE_SECRET_KEY"] = "sk-..." # langfuseから取得
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-..." # langfuseから取得
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = Langfuse()

prompts_response = langfuse.api.prompts.list()

print("Langfuseに登録されてるプロンプト一覧")
for p in prompts_response.data:
    print(f"- '{p.name}' (Label: {p.labels})")

print("---")
prompt = langfuse.get_prompt("foo/recipe")
prompt_value = prompt.compile(dish="カレー")
print(prompt_value)

実行!ユーザープロンプトにプレースホルダーとして登録してあった {{dish}} が、 prompt.compile(dish=カレー) によって置き換わっていることが分かる。

$ uv run langfuse_prompt.py
Langfuseに登録されてるプロンプト一覧
- 'foo/recipe' (Label: ['latest', 'production'])
---
[{'role': 'system', 'content': 'ユーザーが入力した料理のレシピを考えてください。'}, {'role': 'user', 'content': 'カレー'}

Tracing (トレースの収集に関する機能)

次はTracingを試す。書籍の「5.1 Runnbable と RunnableSequence - LCELの最も基本的な構成要素」に相当する。

LangChain の LCEL( | で繋ぐ記法)で実装する。 invoke() にLangfuseの CallbackHandler を渡すだけでLangfuseの画面で処理をトレースできる。

⚠️ | で繋げられるようにLangfuseから取得したpromptを変換する。

# Prompt Template
import os
from langfuse import Langfuse
from langchain_core.prompts import ChatPromptTemplate

os.environ["LANGFUSE_SECRET_KEY"] = "sk-..." # langfuseから取得
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-..." # langfuseから取得
os.environ["LANGFUSE_BASE_URL"] = "http://localhost:3000"
langfuse = Langfuse()
langfuse_prompt = langfuse.get_prompt("foo/recipe")
prompt_messages = langfuse_prompt.get_langchain_prompt() # プロンプトのListを取得する
prompt = ChatPromptTemplate.from_messages(prompt_messages) # LangChain用のPromptTemplateに変換する



# Chat model
from langchain_openai import ChatOpenAI
model = ChatOpenAI(
    base_url = "http://localhost:1234/v1",
    api_key = "dummy",
)



# Output Parser
from langchain_core.output_parsers import StrOutputParser
output_parser = StrOutputParser()


# チェイン
chain = prompt | model | output_parser


# LangfuseのTracing
from langfuse.langchain import CallbackHandler
callback_handler = CallbackHandler()


# 実行
output = chain.invoke({"dish": "カレー"}, config={"callbacks": [callback_handler]})
print(output)

LM Studioのサーバーを起動させ(モデルは openai/gpt-oss-20b をLoadした)、実行してみる!と、Langfuseの画面、左メニューの「Tracing」から見れた!書籍では色々なチェインの組み方でRAGを使う処理を工夫するのだけど、Tracingで見れるのは楽しかった。

ちなみに出力は以下の通り。しっかりカレーのレシピが出ました。

% uv run lcel_langfuse.py
## ざっくりした日本風カレーのレシピ

| ステップ | 内容 |
|--------|------|
| 1. 下ごしらえ | 野菜・肉を切る |
| 2. カレールー作り | 油で炒めてからルーを入れる |
| 3. 煮込み | 水・コク材でじっくり煮る |
| 4. 盛り付け | ご飯とともに仕上げ |

---

### 1️⃣ 下ごしらえ(10分)

| 食材 | 量 | カット例 |
|------|----|----------|
| 鶏もも肉(または牛ひき肉) | 300 g | 1.5 cm角 |
| 玉ねぎ | 2個 | 薄切り |
| にんじん | 1本 | 斜め薄切り |
| ジャガイモ | 2個 | 1.5 cm角 |
| ピーマン | 1個 | くし切り |
| しいたけ | 4枚 | スライス |
| ニンニク | 1片 | みじん切り |
| 生姜 | 1片 | みじん切り |

> **ポイント**
> - 玉ねぎは焦げないように、弱火でじっくり炒めると甘みが出ます。
> - 皮をむいたジャガイモは水にさらしておくと、余分なでんぷんが落ちてテクスチャーが良くなります。

---

### 2️⃣ カレールー作り(15分)

1. **フライパンに油**
   鶏肉または牛ひき肉を入れ、表面が白っぽくなるまで中火で炒める。
   *(牛ひき肉の場合は、余分な脂を軽く切り落とす)*

2. **野菜を加える**
   玉ねぎ、にんじん、ジャガイモ、ピーマン、しいたけを順に加え、5分ほど炒める。
   *玉ねぎが透き通って甘みが出たら次へ。*

3. **にんにく・生姜を投入**
   1〜2分炒めて香りが立ったら、次にカレールーを入れる。
   ここで **市販のルウ**(カレー粉+小麦粉ベース)を使うと簡単。
   - ルーは **10 g × 3〜4個**(約30–40 g)を目安。
   - ルーの粒が大きい場合は、**少量の熱湯で溶かしてから加えると滑らかに仕上がります。**

4. **水を注ぐ**
   ルーが完全に溶けたら、**1.5 Lの水**(またはチキンブイヨン)を加え、全体を混ぜる。
   *水の量はお好みで調整可能です(濃いめにしたいなら少なめ)。*

---

### 3️⃣ 煮込み(20〜30分)

1. **蓋をして弱火**
   15分ほど煮込み、野菜が柔らかくなるまで待つ。
   - 途中で**火を強めて沸騰させ、アク抜き**も忘れずに。

2. **コクを加える**
   - みりん大さじ1、醤油小さじ2、塩少々(約5 g)を加える。
   - さらに**牛乳または生クリーム大さじ3**を加えるとまろやかになります。
   - お好みで **カレー粉大さじ1** も追加してスパイシーに。

3. **仕上げの味付け**
   - 味が足りない場合は、砂糖小さじ1〜2で甘味を調整。
   - **レモン汁小さじ1** で酸味を加えると、風味が引き締まります。

---

### 4️⃣ 盛り付け(5分)

1. **ご飯**
   - 蒸し米を皿に盛り、カレーを上からかける。
   - ご飯は**白米**でも、**玄米**や**雑穀米**でも◎。
2. **トッピング**(任意)
   - パクチー、ゆで卵の黄身を割り入れる。
   - **ピクルス**や**カレーパン**を添えると、味のコントラストが楽しめます。

---

## さらにアレンジしたい方へ

| アレンジ | 方法 |
|----------|------|
| **ベジタリアン** | 肉を外し、豆(レンズ豆やひよこ豆)を加える。 |
| **低糖質** | ジャガイモの代わりにカリフラワーを使用。 |
| **スパイシー** | ターメリック・カイエンペッパーを大さじ1ずつ追加。 |
| **フルーツ風味** | りんごのピューレを大さじ2入れ、甘みと酸味を追加。 |

---

### まとめ

- **下ごしらえ**で食材の味を引き出すことが重要。
- **ルーの溶かし方**を工夫すると、ムラなく滑らかなカレーに仕上がります。
- **最後の味付け**で自分好みの濃さ・スパイシーさを調整できます。

これで、誰でも手軽に作れる本格的な日本風カレーが完成です!ぜひお試しください。

おしまい

LangfuseのPrompts機能を使ってGUIで管理したプロンプトを、そのままローカルLLMに渡し、その実行ログをLangfuseのTracing機能で確認することができました!

次回は、Langfuseの「Evaluation(評価に関する機能)」を使ってRAGの評価に挑みます。

書籍『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を使ってローカルで試していこうと思います。