社内用ツールにローカルLLMを組み込んでコスパよく開発してみた話
「コスパよく」とか言うとZ世代っぽい響きになって若作りできるかなと思って書いてみたのですが、昭和生まれのガチおじさんです(挨拶)。どうもみなさんこんにちは、イプシロンソフトウェアで代表をやっております、渡部です。最近は生成AIすごいですね。一昔前まででは考えられなかったことができるようになっていて時代の流れを感じます。私はあんまりAIのこととかよく分からないのですが、そんな初心者の私が生成AIを組み込む形で社内向けのツールを開発してみた話をしたいと思います。
つくったもの
作ってみたのはメールやWord、Excel等ファイルの自然言語で書かれたタグ付けして検索しやすくするというものです。あくまでも簡易的なツールではあるのですが、社内では結構便利に運用しています。これらのデータはあまり社外に出せないので、一般的なAPIを使うのはちょっと躊躇われ、ローカルLLMというのを試してみることにしました。ローカルLLMを動かすとなるとそれなりにいいスペックのマシンが必要になってくるのですが、そのあたりを工夫してそのあたりに転がってるマシンでコスパよく工夫してやってみましょうというのが今回の企画になります。まぁ社内ツールなのでスクショとかは見せられないのですが。
ローカルLLMの応答速度が遅いという制約があるので、例えばチャットボットなどの応答性が求められるようなLLMの使い方はできません。今回作ったツールにおいてはローカルLLMを使って情報の整理を行うという感じのものだったので、応答性は特に求められませんでした。バッチ処理で文章を読み込み、読み込んだ結果をデータベースに格納し直して検索できればいいですからね。データベースから検索を行う部分は島根県民らしくRuby on Railsを使って開発を行いました。
【コツ1】 処理パイプラインを組む
最近の生成AIってとても賢いですよね。あれをやってくれ、これをやってくれと複雑に自然言語で命令してもうまい具合に応答を返してくれます。しかしながら賢い応答にはマシンスペックが必要になってきます。今回の企画では「コスパよく」でありマシンスペックはいいものではありません。その状態で一気にプロンプトに命令を出すとAIからの応答が頭悪い感じになってしまいます。マシンスペックが低い場合は処理の内容をいくつかの段階に分け、全体をパイプラインのように扱ってあげれば低いスペックでもそれなりの応答を得ることができます。プロンプト全体が小さくまとまってやるべきことが少なければスピードも上がります。
【コツ2】プログラムで行える処理はプログラムで行う
LLMを使って処理を行えるようになってくると何でもかんでもプロンプトエンジニアリングで解決しようとしてしまいがちです。ですが、本当にその処理にLLMが必要なのかはよくよく考える必要があるかと思います。事前にある程度パターンが想定できるような処理であれば正規表現を使った検索や置換等で十分という場合もあります。例えば今回作ったシステムではメール整理を行いますが、メールの本文と署名部分の分離については正規表現レベルで十分でした。
お世話になっております。
例の件どうなりましたでしょうか?
13時ごろお電話します。
よろしくお願いいたします。
****************************************************
会社名 この部分を除去したい
削除部
削除 太郎
〒000-0000 東京都〇〇〇〇
TEL:03-0000-0000 FAX: 03-0000-0000
Email:××××××@××××.com
URL: https://××××.com/
****************************************************LLMを使わなくても処理できるようであれば、LLMを使わないほうが速度的にも安定性的にも圧倒的に有利です。このあたりの見極めが重要なのかなと思いました。
【コツ3】structured outputsを使う
今回のシステム全体としては、全体をdocker composeとして動かし、Webサーバーとは別にOllamaのコンテナを立ち上げる感じで設計しました。Ollamaについているstructured outputsという機能を使うとLLMからの応答が指定したJSONスキーマの形式に従ってくれます(たまに従ってくれないけど)。JSONで応答を得ることができればプログラムで組んだ部分といい感じに連携することができます。今回はWebサーバー側の実装をRuby on Railsで行いましたのでollama-rubyというライブラリを使っています。このollama-rubyを使ってLLMに質問を出し、structured outputsで応答のフォーマットを制限するサンプルコードが以下のようになります。
require "ollama"
client = Ollama::Client.new(base_url: 'http://ollama:11434/')
prompt = "アイルトンセナに関する情報をJSONで出力してください。"
schema = {
"type" => "object",
"properties" => {
"name" => { "type" => "string" },
"country" => { "type" => "string" },
"team" => { "type" => "array", "items" => { "type" => "string" } },
"birthday" => { "type" => "string" },
"died_at" => { "type" => "string" }
},
"required" => [ "name", "country", "team", "birthday", "died_at" ]
}
begin
response = client.generate(
model: "qwen3:8b",
prompt: prompt,
stream: false,
format: schema
)
puts "生成内容:"
puts response.response
rescue => e
puts "GenerateAPIエラー: #{e.class}: #{e.message}"
puts "バックトレース:"
puts e.backtrace.first(5).join("\n")
end
そうですね、ここのところNetflixでセナのドラマもめちゃめちゃ面白かったですし、ブラピのF1の映画もめちゃめちゃ面白かったですし、F1盛り上がってますね! 応答としては以下のようになります。
# ruby test_structured_output.rb
生成内容:
{
"name": "Ayrton Senna",
"country": "Brazil",
"team": [
"McLaren",
"Williams",
"Tyrrell",
"Lotus",
"Brabham",
"Ferrari",
"Renault"
],
"birthday": "March 21, 1954",
"died_at": "May 1, 1994"
}ちょいちょい実際とは違うところではありますが、そのあたりは知識の問題ということでいったんおいておくと、指定したスキーマどおりに正しく出力することができました。これが一般的なプロンプトのままで自然言語の出力を行わせると、ブロックの中に埋め込まれたJSONがスキーマに従ってなかったり、パースできないような壊れたJSONが出力されてしまったりとかいろいろと面倒なこともありますが、structured outputsを使えばそのような問題とは無縁になります。めでたしめでたし。
宣伝
あ、最後に宣伝です。タダより高いものは無い。イプシロンソフトウェアでは現在(2025年9月現在) 営業職を中心に活躍していただけるメンバーを募集中です。我こそはと思う方はご応募ください。
https://www.wantedly.com/projects/2181965
以上、宣伝でした。