0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DSPy × Ollama でゼロコスト・完全ローカルなLLMパイプラインを作る

0
Posted at

はじめに

APIコスト不要、データ外部送信なし。DSPyの自動最適化をOllamaのローカルLLMと組み合わせ、プロダクション品質のLLMパイプラインをオンプレミスで構築する方法を解説します。

1. なぜDSPy × Ollamaなのか

LLMを業務システムに組み込む際、クラウドAPIには次のような課題があります。

  • コスト
    大量の取引データを処理するとトークン料金がかさむ
  • プライバシー
    会計データ・個人情報を外部サーバーに送信するリスク
  • 可用性
    API障害やレートリミットに依存する

OllamaはオープンソースのLLMをローカルで実行するランタイムです。DSPyはOllamaと組み合わせることで、APIコストゼロ・完全オフラインで自動最適化済みのLLMパイプラインを構築できます。

DSPy(パイプライン定義 + 自動最適化)
    ↓
LiteLLM(プロバイダー抽象化)
    ↓
Ollama(ローカルLLMサーバー)
    ↓
llama3.2 / gemma3 / qwen2.5 ...(モデル)

2. 環境構築

Ollamaのインストール

# macOS / Linux
curl -fsSL https://ollama.com/install.sh | sh
# macOS(Homebrew)
brew install ollama

Windowsの場合は ollama.com から公式インストーラーをダウンロードします。

Ollamaの起動とモデルのダウンロード

# サーバーを起動
ollama serve

# モデルをpull(別ターミナルで実行)
ollama pull llama3.2        # 3B、軽量・高速
ollama pull gemma3:4b       # 4B、バランス良好
ollama pull qwen2.5:7b      # 7B、日本語が得意

# 起動確認
ollama list

DSPyのインストール

pip install dspy optuna

3. DSPyからOllamaに接続する

DSPyはLiteLLMを介してOllamaと通信します。接続には dspy.LM() を使い、モデル名に ollama/ または ollama_chat/ プレフィックスを付けます。

import dspy

lm = dspy.LM(
    model="ollama_chat/gemma3:4b",      # チャット形式モデル
    api_base="http://localhost:11434",  # OllamaのデフォルトURL
)
dspy.configure(lm=lm)

ollama/ollama_chat/ の違い

プレフィックス 対象 推奨モデル
ollama/ テキスト補完形式(旧来のcompletion API) mistral、旧世代モデル
ollama_chat/ チャット形式(推奨) llama3系、gemma3、qwen2.5

最近のモデルは ollama_chat/ を使う方が安定します。

接続パラメータ

lm = dspy.LM(
    model="ollama_chat/gemma3:4b",
    api_base="http://localhost:11434",
    temperature=0.0,    # 再現性が必要な場合は0に設定
    max_tokens=1000,    # 最大出力トークン数
    timeout=120,        # タイムアウト(秒)、大きめに設定推奨
)

4. 基本的な動作確認

シンプルな問答

import dspy

lm = dspy.LM("ollama_chat/gemma3:4b", api_base="http://localhost:11434")
dspy.configure(lm=lm)

qa = dspy.Predict("question -> answer")
result = qa(question="消耗品費と備品費の違いを教えてください")
print(result.answer)

ChainOfThoughtで推論を可視化

import dspy

lm = dspy.LM("ollama_chat/gemma3:4b", api_base="http://localhost:11434")
dspy.configure(lm=lm)

cot = dspy.ChainOfThought("question -> answer")
result = cot(question="消耗品費と備品費の違いを教えてください")

print("推論過程:", result.reasoning)
print("回答:", result.answer)

5. 実践例:会計データの勘定科目自動分類

会計の摘要文から勘定科目を自動判定するシステムを実装します。

ファイル構成

project/
├── make_train_data.py               # 学習・最適化スクリプト(まずこれを実行)
├── test.py                          # 推論CLIスクリプト(最適化後に使う)
├── account_classifier_optimized.json  # 最適化後に自動生成される
├── 経理データ・05年12月.csv
├── 経理データ・06年12月.csv
└── 経理データ・07年12月.csv

スクリプトとCSVファイルを同じディレクトリに置きます。make_train_data.pyPath(__file__).resolve().parent でCSVを探すため、実行ディレクトリに依存しません。

make_train_data.py — 学習・最適化スクリプト

会計CSVを読み込み、DSPyのMIPROv2オプティマイザーで分類器を最適化します。実行は初回のみで、最適化結果はJSONに保存されます。

import dspy
import csv
import re
from pathlib import Path

# ── Ollama接続 ────────────────────────────────────────────────────────────────
lm = dspy.LM(
    model="ollama_chat/gemma3:4b",
    api_base="http://localhost:11434",
)
dspy.configure(lm=lm)

# ── 勘定科目リスト ────────────────────────────────────────────────────────────
ACCOUNT_CATEGORIES = [
    "消耗品費", "交際費", "サービス利用料", "会議費", "通信費",
    "水道光熱費", "車輛費", "支払手数料", "外注費", "支払家賃",
    "諸会費", "旅費交通費", "福利厚生費", "雑費", "租税公課",
    "損害保険料", "修繕費", "法定福利費", "支払利息", "新聞図書費",
]

# ── Signature(入出力仕様)────────────────────────────────────────────────────
class ClassifyAccount(dspy.Signature):
    """
    支出の摘要と金額から、適切な勘定科目を判定する。
    勘定科目は必ず以下のリストから選ぶこと: {account_list}
    """
    expense_description = dspy.InputField(desc="支出の摘要(取引先・購入品目を含む)")
    amount              = dspy.InputField(desc="取引金額(円)")
    tax_type            = dspy.InputField(desc="消費税区分(内10、内8軽、空白)")
    account_list        = dspy.InputField(desc="選択可能な勘定科目のリスト")
    account_category    = dspy.OutputField(desc="判定した勘定科目名(リストから1つ選択)")
    reason              = dspy.OutputField(desc="判定理由(監査ログ用)")

# ── Module(実行層)───────────────────────────────────────────────────────────
class AccountClassifier(dspy.Module):
    def __init__(self):
        # ChainOfThoughtで推論過程を明示させる
        self.classify = dspy.ChainOfThought(ClassifyAccount)

    def forward(self, expense_description, amount, tax_type=""):
        return self.classify(
            expense_description=expense_description,
            amount=amount,
            tax_type=tax_type,
            account_list="".join(ACCOUNT_CATEGORIES),
        )

# ── 評価指標 ──────────────────────────────────────────────────────────────────
def account_accuracy(example, prediction, trace=None):
    """正答率(完全一致)"""
    return example.account_category == prediction.account_category

# ── CSVの読み込み ──────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent

def find_csv_by_year(year_prefix):
    matches = sorted(BASE_DIR.glob(f"*{year_prefix}年12月.csv"))
    if not matches:
        raise FileNotFoundError(f"{year_prefix}年12月のCSVが見つかりません: {BASE_DIR}")
    return matches[0]

def load_training_data(filepath):
    """会計CSVからDSPy用のtrainsetを作成"""
    EXPENSE_ACCOUNTS = set(ACCOUNT_CATEGORIES)
    trainset = []

    with open(filepath, encoding='cp932') as f:
        rows = list(csv.reader(f))

    for row in rows:
        # 番号列が数値の行がデータ行
        if not row or not row[0].strip().isdigit():
            continue

        account = re.sub(r'\s+', '', row[5])   # 全角スペース除去
        if account not in EXPENSE_ACCOUNTS:
            continue

        example = dspy.Example(
            expense_description=row[16].strip(),
            amount=row[13].strip(),
            tax_type=row[14].strip(),
            account_category=account,
        ).with_inputs("expense_description", "amount", "tax_type")

        trainset.append(example)

    return trainset

# 全CSVを読み込んで件数確認
all_data = []
for path in sorted(BASE_DIR.glob("*.csv")):
    all_data.extend(load_training_data(path))
print(f"学習データ件数: {len(all_data)}")

# 年度別にtrain/validationに分割(05年・06年 → train、07年 → validation)
trainset   = load_training_data(find_csv_by_year("05"))
trainset  += load_training_data(find_csv_by_year("06"))
valset     = load_training_data(find_csv_by_year("07"))
print(f"train件数: {len(trainset)}")
print(f"validation件数: {len(valset)}")

# ── MIPROv2で最適化 ───────────────────────────────────────────────────────────
optimizer = dspy.MIPROv2(
    metric=account_accuracy,
    auto="light",   # "light" / "medium" / "heavy" で試行回数を調整
)

optimized_classifier = optimizer.compile(
    AccountClassifier(),
    trainset=trainset,
    valset=valset,
)

# 最適化済みモジュールを保存
optimized_classifier.save("account_classifier_optimized.json")
print("最適化完了")

コードのポイント

パート 役割
ClassifyAccount(Signature) 入出力の仕様を宣言。HOW(プロンプト文面)ではなくWHAT(何を入れて何を出すか)を定義する
AccountClassifier(Module) ChainOfThoughtでSignatureを実行。推論ステップを出力させることで監査ログにも使える
find_csv_by_year() 年号プレフィックスでCSVを自動検索。ファイル名が変わっても柔軟に対応できる
account_accuracy 正解ラベルと予測の完全一致で正答率を計算。オプティマイザーの目的関数になる
MIPROv2(auto="light") 少数ショット例の合成と指示文の自動改善を行う。lightは最小試行回数でローカル環境向け
.save() 最適化後のプロンプトとデモをJSONに保存。次回は.load()で即利用可能

6. 推論・テスト用CLIスクリプト

make_train_data.py で生成した account_classifier_optimized.json をロードし、摘要と金額を渡して勘定科目を1件判定します。

test.py
# 使い方:
#   python test.py "ノートパソコン" "150000" --tax-type "内10"

import argparse
from pathlib import Path

import dspy

ACCOUNT_CATEGORIES = [
    "消耗品費", "交際費", "サービス利用料", "会議費", "通信費",
    "水道光熱費", "車輛費", "支払手数料", "外注費", "支払家賃",
    "諸会費", "旅費交通費", "福利厚生費", "雑費", "租税公課",
    "損害保険料", "修繕費", "法定福利費", "支払利息", "新聞図書費",
]

MODEL_PATH = Path(__file__).resolve().parent / "account_classifier_optimized.json"


class ClassifyAccount(dspy.Signature):
    """
    支出の摘要と金額から、適切な勘定科目を判定する。
    勘定科目は必ず以下のリストから選ぶこと: {account_list}
    """
    expense_description = dspy.InputField(desc="支出の摘要(取引先・購入品目を含む)")
    amount              = dspy.InputField(desc="取引金額(円)")
    tax_type            = dspy.InputField(desc="消費税区分(内10、内8軽、空白)")
    account_list        = dspy.InputField(desc="選択可能な勘定科目のリスト")
    account_category    = dspy.OutputField(desc="判定した勘定科目名(リストから1つ選択)")
    reason              = dspy.OutputField(desc="判定理由(監査ログ用)")


class AccountClassifier(dspy.Module):
    def __init__(self):
        self.classify = dspy.ChainOfThought(ClassifyAccount)

    def forward(self, expense_description, amount, tax_type=""):
        return self.classify(
            expense_description=expense_description,
            amount=amount,
            tax_type=tax_type,
            account_list="".join(ACCOUNT_CATEGORIES),
        )


def build_parser():
    parser = argparse.ArgumentParser(description="最適化済み勘定科目分類器を試す")
    parser.add_argument("expense_description", help="支出の摘要")
    parser.add_argument("amount", help="取引金額(円)")
    parser.add_argument("--tax-type", default="", help="消費税区分。例: 内10, 内8軽")
    parser.add_argument(
        "--model",
        default="ollama_chat/gemma3:4b",
        help="使用する Ollama モデル名",
    )
    parser.add_argument(
        "--api-base",
        default="http://localhost:11434",
        help="Ollama API の URL",
    )
    return parser


def main():
    args = build_parser().parse_args()

    if not MODEL_PATH.exists():
        raise FileNotFoundError(f"最適化済みモデルが見つかりません: {MODEL_PATH}")

    lm = dspy.LM(model=args.model, api_base=args.api_base)
    dspy.configure(lm=lm)

    classifier = AccountClassifier()
    classifier.load(str(MODEL_PATH))

    prediction = classifier(
        expense_description=args.expense_description,
        amount=args.amount,
        tax_type=args.tax_type,
    )

    print(f"摘要: {args.expense_description}")
    print(f"金額: {args.amount}")
    print(f"税区分: {args.tax_type or '(空白)'}")
    print(f"勘定科目: {prediction.account_category}")
    print(f"理由: {prediction.reason}")


if __name__ == "__main__":
    main()

実行例

$ python test.py "カード/AMAZON.CO.JP:HDMIケーブル" "2574" --tax-type "内10"
摘要: カード/AMAZON.CO.JP:HDMIケーブル
金額: 2574
税区分: 内10
勘定科目: 消耗品費
理由: HDMIケーブルは事業用の消耗品に該当するため

$ python test.py "ノートパソコン" "150000" --tax-type "内10"
摘要: ノートパソコン
金額: 150000
税区分: 内10
勘定科目: 消耗品費
理由: ノートパソコンは事業用の消耗品として計上されるため

デフォルトでは gemma3:4b を使いますが、--model オプションでモデルを切り替えられます。

python test.py "クラウドストレージ料金" "3300" --tax-type "内10" --model "ollama_chat/qwen2.5:7b"

7. モデル選定ガイド

日本語の会計テキストを扱う場合のモデル選択指針です。

モデル サイズ 日本語品質 速度 VRAM目安 推奨用途
llama3.2 3B 2GB 開発・動作確認
llama3.1:8b 8B 6GB バランス重視
gemma3:4b 4B 4GB バランス重視
qwen2.5:7b 7B 5GB 日本語優先
qwen2.5:14b 14B 10GB 精度優先
mistral:7b 7B 5GB 汎用

日本語の会計テキスト分類には qwen2.5:7b が特に有効です。日本語コーパスを多く学習しており、専門用語の理解精度が高い傾向があります。

# qwen2.5を使う場合
lm = dspy.LM(
    "ollama_chat/qwen2.5:7b",
    api_base="http://localhost:11434",
    temperature=0.0,
)
dspy.configure(lm=lm)

8. トラブルシューティング

接続エラー: Connection refused

# Ollamaが起動しているか確認
curl http://localhost:11434/api/tags

# 起動していない場合
ollama serve

出力が構造化されない(account_category が空になる)

DSPyはLLMに対してJSON形式での出力を要求しますが、小さなモデルでは崩れることがあります。

# Ollamaのnative JSON modeを有効にする
lm = dspy.LM(
    "ollama_chat/llama3.2",
    api_base="http://localhost:11434",
    # LiteLLM経由でOllamaのformatオプションを渡す
    extra_body={"format": "json"},
)

または、より大きなモデルに切り替えることで解決することが多いです。

最適化が遅い

MIPROv2 はデフォルトで多数のLLM呼び出しを行います。ローカル環境では auto="light" から始めてください。

optimizer = dspy.MIPROv2(metric=account_accuracy, auto="light")

タイムアウトが発生する

大きなモデルは推論に時間がかかります。タイムアウトを延ばしてください。

lm = dspy.LM(
    "ollama_chat/qwen2.5:14b",
    api_base="http://localhost:11434",
    timeout=300,   # 5分
)

9. まとめ

DSPy × Ollamaの組み合わせで実現できること:

項目 内容
コスト OpenAI APIのトークン料金が不要
プライバシー 会計データ・個人情報が外部に出ない
可用性 API障害・レートリミットに依存しない
自動最適化 BootstrapFewShot・MIPROv2がローカルで動く
モデル切替 dspy.configure(lm=...) の1行で切り替え可能
# 完全な初期化コード(コピーして使える)
import dspy

lm = dspy.LM(
    model="ollama_chat/qwen2.5:7b",
    api_base="http://localhost:11434",
    temperature=0.0,
    timeout=120,
)
dspy.configure(lm=lm)

プライバシーが求められる業務データ(会計・医療・法務など)をLLMで処理したい場合、DSPy × Ollamaは実用的な選択肢です。DSPyの宣言的なプログラミングモデルにより、モデルをクラウドAPIに切り替える際もコードの変更は最小限で済みます。

参考リソース

リソース URL
DSPy 公式サイト https://dspy.ai
DSPy GitHub https://github.com/stanfordnlp/dspy
Ollama 公式サイト https://ollama.com
Ollama GitHub https://github.com/ollama/ollama
LiteLLM(DSPy内部で使用) https://docs.litellm.ai
利用可能なOllamaモデル一覧 https://ollama.com/library
0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?