ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

langchain.rb で OpenAI / Azure OpenAI Service を使ってみる

みなさんこんにちは、ユニファの赤沼です。 この記事は Unifa Advent Calendar 2023 の3日目の記事です。

adventar.org

昨日の記事では ruby-openai で OpenAI API を使ってみる記事を書きましたが、今日はさらに LangChain の Ruby版と言える langchain.rb での OpenAI / Azure OpenAI Service へのアクセスを試してみたのでその内容を書きたいと思います。

github.com

Gemfile

Gemfile には下記のように記載して bundle install を実行します。

gem "langchainrb"

API KEY などの設定

ruby-openai のときと同じように OpenAI の API KEY を環境変数に設定しておきます。

$ export OPENAI_API_KEY="sf-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

Azure OpenAI Service 用の環境変数も同様に設定しておきます。

$ export AZURE_OPENAI_API_KEY="XXXXXXXXXXXXXXXXXXXXXXXX"
$ export URI_BASE="https://XXXXXXXXXXX.openai.azure.com/openai/deployments/gpt-35-turbo/"
$ export API_VERSION="2023-07-01-preview"

ChatCompletion

まずはシンプルに ChatCompletion API を試してみます。基本的なイメージは ruby-openai 利用時と同様ですが、使用するクラスが langchain.rb のものになり、パラメータの渡し方が少し変わります。

require 'langchain'

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch('OPENAI_API_KEY'),
  default_options: {
    chat_completion_model_name: 'gpt-3.5-turbo'
  }
)

messages = [
  { role: 'system', content: 'You are a helpful assistant.' },
  { role: 'user',   content: 'こんにちは!私はジョンと言います!' },
  { role: 'system', content: 'こんにちは、ジョンさん!どのようにお手伝いできますか?' },
  { role: 'user',   content: '私の名前がわかりますか?' }
]

response = llm.chat(messages: messages)

puts response.raw_response.dig('choices', 0, 'message', 'content')

上記を実行すると下記のようなレスポンスが返ります。

はい、先ほどジョンさんと名乗られましたので、お名前はジョンさんですね。どのようなお手伝いが必要でしょうか?

今度は Azure OpenAI Service の方で ChatCompletion を試してみます。こちらも使用するクラスが langchain.rb のものになり、パラメータの渡し方が少し変わりますが基本的なイメージは同様です。

require 'langchain'

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch('AZURE_OPENAI_API_KEY'),
  llm_options: {
    api_type:    :azure,
    api_version: ENV.fetch('API_VERSION'),
    uri_base:    ENV.fetch('URI_BASE')
  }
)

messages = [
  { role: 'system', content: 'You are a helpful assistant.' },
  { role: 'user',   content: 'こんにちは!私はジョンと言います!' },
  { role: 'system', content: 'こんにちは、ジョンさん!どのようにお手伝いできますか?' },
  { role: 'user',   content: '私の名前がわかりますか?' }
]

response = llm.chat(messages: messages)

puts response.raw_response.dig('choices', 0, 'message', 'content')

Streaming

次にストリーミングを試してみます。 Langchain::Conversation クラスを使用し、初期化時に OpenAI のクライアントのインスタンスを渡して、 chunk を受け取るごとに実行する内容を指定します。

require 'langchain'

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch('OPENAI_API_KEY'),
  default_options: {
    chat_completion_model_name: 'gpt-3.5-turbo'
  }
)

chat = Langchain::Conversation.new(llm: llm) do |chunk|
  print chunk.dig('delta', 'content')
end

chat.message('自己紹介してください。')

これを実行すると下記のような内容が徐々に表示されていきます。

こんにちは、私はAIです。私はOpenAIが開発した自然言語処理モデルです。私の目的は、ユーザーが質問や要求をすると、最善の回答や応答を提供することです。私は様々なトピックについての情報を持っており、文法やスペルの修正も行うことができます。どのようにお手伝いできますか?

Azure OpenAI Service では下記のようになります。 chunk が返されないケースが有るため、 chunk が nil ではないときのみ dig で content を取り出すようにしています。

require 'langchain'

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch('AZURE_OPENAI_API_KEY'),
  llm_options: {
    api_type:    :azure,
    api_version: ENV.fetch('API_VERSION'),
    uri_base:    ENV.fetch('URI_BASE')
  }
)

chat = Langchain::Conversation.new(llm: llm) do |chunk|
  print chunk.dig('delta', 'content') unless chunk.nil?
end

chat.message('自己紹介してください')

これを実行すると下記のような内容が徐々に表示されていきますが、 ruby-openai のときと同様に OpenAI と比較すると一気に出力されるような感じです。

はい、私はオープンAIの言語モデルであるGPT-3です。私は自然言語処理の能力を持ち、様々なトピックについての質問や会話に応えることができます。私は大量のデータを学習しており、文章の生成や意味の理解、文脈に基づいた回答などを行うことができます。私の目標は、人々がより簡単に情報を得たり、問題を解決したりするのを支援することです。どのようにお手伝いできますか?

PromptTemplate

langchain.rb には本家と同様に PromptTemplate も用意されています。一番シンプルなケースとしては下記のようになります。

require 'langchain'

template = """
以下の都道府県の観光名所を教えてください。

都道府県名: {prefecture}
"""

prompt = Langchain::Prompt::PromptTemplate.new(
  template: template,
  input_variables: ['prefecture']
)

result = prompt.format(prefecture: '埼玉県')

puts result

これを実行すると下記のようにテンプレートに入力値が組み込まれた形で出力されます。

以下の都道府県の観光名所を教えてください。

都道府県名: 埼玉県

上記の例ではテンプレートに必要な入力変数を明示的に指定していましたが、下記のようにテンプレートから自動的に判別することもできます。

require 'langchain'

template = """
以下の都道府県の観光名所を教えてください。

都道府県名: {prefecture}
"""

prompt = Langchain::Prompt::PromptTemplate.from_template(template)
pp prompt.input_variables # => ["prefecture"]

result = prompt.format(prefecture: "埼玉県")

puts result

テンプレートは下記のようにファイルに保存することもできます。

require 'langchain'

template = """
以下の都道府県の観光名所を教えてください。

都道府県名: {prefecture}
"""

prompt = Langchain::Prompt::PromptTemplate.from_template(template)
prompt.save(file_path: "prompt_template.json")

保存したテンプレートをファイルから読み出すには下記のようにします。

require 'langchain'

prompt = Langchain::Prompt.load_from_path(file_path: "prompt_template.json")
pp prompt

これを実行するとテンプレートが読み込まれてインスタンスが返されていることがわかります。

#<Langchain::Prompt::PromptTemplate:0x0000ffff8d2e9770
 @input_variables=["prefecture"],
 @template="\n" + "以下の都道府県の観光名所を教えてください。\n" + "\n" + "都道府県名: {prefecture}\n",
 @validate_template=true>

FewShotPromptTemplate

Few-shot Learning 用のテンプレートも用意されています。例示用のサンプルの情報とそれを読み込ませる PromptTemplate を用意して、 FewShotPromptTemplate の初期化時に渡します。

require 'langchain'

examples = [
  {input: "暑い", output: "寒い"},
  {input: "高い", output: "低い"}
]

example_prompt = Langchain::Prompt::PromptTemplate.new(
  input_variables: ["input", "output"],
  template:        "入力: {input}\n出力: {output}"
)

prompt = Langchain::Prompt::FewShotPromptTemplate.new(
  prefix:          "次の単語の対義語を答えてください。",
  suffix:          "入力: {input}\n出力: ",
  example_prompt:  example_prompt,
  examples:        examples,
  input_variables: ["input"]
)

puts prompt.format(input: "明るい")

これを実行すると下記のように Few-shot Learning 形式でのプロンプトが出力されます。

次の単語の対義語を答えてください。

入力: 暑い
出力: 寒い

入力: 高い
出力: 低い

入力: 明るい
出力: 

StructuredOutputParser

OutputParser には StructuredOutputParser が用意されています。LLMからのレスポンスとして期待する json のスキーマを設定し、 StructuredOutputParser.from_json_schema に渡すことで Parser のインスタンスを生成します。

そして PromptTemplate をフォーマットする際に Parser から get_format_instructions でフォーマット指示内容を取得して渡します。最終的に生成されたプロンプトを ChatCompletion 呼び出し時に渡します。

ちなみに下記の json スキーマは langchain.rb の README に書かれているサンプルを日本語化したものです。

require 'langchain'

json_schema = {
  type: 'object',
  properties: {
    name: {
      type: 'string',
      description: '人物の名前'
    },
    age: {
      type: 'number',
      description: '人物の年齢'
    },
    interests: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          interest: {
            type: 'string',
            description: '興味のあるトピック'
          },
          levelOfInterest: {
            type: 'number',
            description: '興味の度合いを0から100の間で表した値'
          }
        },
        required: %w[interest levelOfInterest],
        additionalProperties: false
      },
      minItems: 1,
      maxItems: 3,
      description: '人物の興味リスト'
    }
  },
  required: %w[name age interests],
  additionalProperties: false
}
parser = Langchain::OutputParsers::StructuredOutputParser.from_json_schema(json_schema)

prompt = Langchain::Prompt::PromptTemplate.new(
  template: "架空のキャラクターの詳細を生成してください。\n{format_instructions}\nキャラクターの説明: {description}",
  input_variables: %w[description format_instructions]
)
prompt_text = prompt.format(
  description: '韓国の化学学生',
  format_instructions: parser.get_format_instructions
)

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch('OPENAI_API_KEY')
)
response = llm.chat(prompt: prompt_text)
puts JSON.pretty_generate(parser.parse(response.raw_response.dig('choices', 0, 'message', 'content')))

これを実行すると下記のようなレスポンスが返ります。

{
  "name": "韓国の化学学生",
  "age": 21,
  "interests": [
    {
      "interest": "有機化学",
      "levelOfInterest": 80
    },
    {
      "interest": "分析化学",
      "levelOfInterest": 70
    },
    {
      "interest": "物理化学",
      "levelOfInterest": 60
    }
  ]
}

Azure OpenAI Service では llm のインスタンス生成が下記のように変わるだけで、その他は同様です。

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch('AZURE_OPENAI_API_KEY'),
  llm_options: {
    api_type: :azure,
    api_version: ENV.fetch('API_VERSION'),
    uri_base: ENV.fetch('URI_BASE')
  }
)
response = llm.chat(prompt: prompt_text)
puts JSON.pretty_generate(parser.parse(response.raw_response.dig('choices', 0, 'message', 'content')))

Vector DB へのドキュメントの追加と検索も試してみます。 langchain.rb では様々な Vector DB に対応していますが、今回は Chroma を使ってみます。

docs.trychroma.com

Gemfile には下記のように記載して bundle install を実行します。今回読み込ませるドキュメントとしてPDFを使っているので pdf-reader の gem も使用します。

gem "chroma-db"
gem "pdf-reader"

Python での事例では実装の中で直接 Chroma でローカルにデータを保存しているケースが多いですが、 langchain.rb では URL 指定で Chroma のサーバにアクセスする必要があるため、下記のように Chroma をサーバモードで起動しておきます。

$ chroma run --path .chroma_data --port 8088 --host 127.0.0.1

Chroma のURLは下記のように環境変数に設定しておきます。

$ export CHROMA_URL="http://127.0.0.1:8088"

Chroma のクライアント/サーバモードについては下記ドキュメントに記載されています。

docs.trychroma.com

今回読み込むドキュメントのサンプルとしては、執筆時点の少し前に発表された「セキュアAIシステム開発ガイドラインについて」のPDFファイルを使用します。

セキュアAIシステム開発ガイドラインについて(PDFファイル)

まずは Chroma に上記のドキュメントの情報を追加します。

Langchain::Vectorsearch::Chroma の初期化時に Chroma のURLとインデックス名、LLMのインスタンスを渡します。

create_default_schema は初回のみ実行が必要です。

読み込ませるドキュメントのパスを指定してリストとして chroma.add_data に渡します。

require "langchain"

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch("OPENAI_API_KEY")
)

chroma = Langchain::Vectorsearch::Chroma.new(
  url:        ENV.fetch("CHROMA_URL"),
  index_name: "documents",
  llm:        llm
)

chroma.create_default_schema

docs = [
  Langchain.root.join("/path/to/guideline.pdf")
]

pp chroma.add_data(
  paths: docs
)

格納されたドキュメントの類似検索は下記のように similarity_search メソッドで行います。 k は返すドキュメントの数の指定です。

require "langchain"

llm = Langchain::LLM::OpenAI.new(
  api_key: ENV.fetch("OPENAI_API_KEY")
)

chroma = Langchain::Vectorsearch::Chroma.new(
  url:        ENV.fetch("CHROMA_URL"),
  index_name: "documents",
  llm:        llm
)

pp chroma.similarity_search(
  query: "CISA",
  k: 1
)

これを実行すると下記のように検索対象文字列が含まれる前半のドキュメントが返ります。

[#<Chroma::Resources::Embedding:0x0000ffff7e532810
  @distance=0.40323304717107916,
  @document=
   "報道資料\n" +
   "\n" +
   "\n" +
   "                                 令和5年11月28日\n" +
   "\n" +
   "                      内閣府科学技術・イノベーション推進事務局\n" +
   "                      内閣官房内閣サイバーセキュリティセンター\n" +
   "\n" +
   "\n" +
   "       セキュア AI システム開発ガイドラインについて\n" +
   "\n" +
   "\n" +
   "1.概要\n" +
   "\n" +
   " 内閣府科学技術・イノベーション推進事務局及び内閣サイバーセキュリティセンターは、\n" +
   "\n" +
   "英国国家サイバーセキュリティセンター(NCSC)が米国サイバーセキュリティ・インフラ\n" +
   "ストラクチャー安全保障庁(CISA)等とともに作成した「セキュアAI システム開発ガイド\n" +
   "ライン」(以下「本件文書」という)の「共同署名」(本件文書作成への協力機関として組\n" +
   "\n" +
   "織名を列記:日本のほかG7 各国を含む計18 か国が参加)に加わり、本件文書を公表しま\n" +
   "した。なお、本件文書は広島AI プロセスを補完するものであり、参考文書として、高度な\n" +
   "\n" +
   "AI システムを開発する組織向けの広島プロセス国際指針及び国際行動規範が記載されて\n" +
   "います。\n" +
   " 本件文書は、セキュアバイデザイン(IT 製品(特にソフトウェア)について、セキュリ\n" +
   "\n" +
   "ティを確保した設計を行うこと)の観点から、ソフトウェアのうちAI に焦点を当てて、AI\n" +
   "を使用するシステムのプロバイダーによるセキュアな AI システムの構築を支援するため\n" +
   "\n" +
   "の指針となっております。\n" +
   " 内閣府科学技術・イノベーション推進事務局及び内閣サイバーセキュリティセンターは、\n" +
   "本件文書が、高度なAI システムを開発する組織向けの行動規範を作成するG7 広島AI プロ\n" +
   "\n" +
   "セスを補完し、AI のセキュリティ部分について詳述する文書であること、関係国に対する\n" +
   "G7 広島 AI プロセスのアウトリーチ活動にも資すること、各国の関係機関と日本の協力関\n" +
   "\n" +
   "係の基礎となること等の事由により、共同署名に加わりました。\n" +
   " 今後は、技術の進歩が早い分野であることも踏まえ、本件文書の具体化に当たり、産業\n" +
   "界とも継続的に対話を重ねつつ、引き続き、AI 及びサイバーセキュリティ分野での国際連\n" +
   "\n" +
   "携の強化に努めてまいります。\n" +
   "\n" +
   "\n" +
   "2.本件文書の内容\n" +
   "\n" +
   "(1)導入\n" +
   "   サイバーセキュリティはAI システムの前提条件。",
  @embedding=nil,
  @id="3ca64a51-8181-4d87-9ad5-72e626c41229",
  @metadata=nil>]

格納された情報を元に回答を返すには ask メソッドを利用します。

response = chroma.ask(
  question: "ガイドラインの作成に関わった国はどこですか?"
)

pp response.raw_response.dig("choices", 0, "message", "content")

これを実行すると下記のようにドキュメントの情報を元にしたから回答が返されます。

"ガイドラインの作成に関わった国は、日本を含む計18か国です。"

では同様のことを Azure OpenAI Service でもやってみます。

OpenAI と主に異なる点としてはエンドポイントのURLの指定です。これまでの Azure OpenAI Service の例では Chat のモデルしか使用していなかのですが、今回のケースでは Embedding と Chat の両方が必要になります。Azure OpenAI Service ではエンドポイントのURLにモデルの指定も含まれる形になるため、それぞれのエンドポイントのURLを用意しておく必要があります。

$ export CHAT_URI_BASE="https://XXXXXXXXXXX.openai.azure.com/openai/deployments/gpt-35-turbo/"
$ export EMBED_URI_BASE="https://XXXXXXXXXXX.openai.azure.com/openai/deployments/text-embedding-ada-002/"

また、今までは LLM の OpenAI 用のクラスを使用していましたが、上記の URL の使い分けが必要になるため、 Azure 用のクラスを使用します。

それを踏まえて、ドキュメントの追加は下記のようになります。

require "langchain"

llm = Langchain::LLM::Azure.new(
  api_key:                  ENV.fetch("AZURE_OPENAI_API_KEY"),
  embedding_deployment_url: ENV.fetch("EMBED_URI_BASE"),
  chat_deployment_url:      ENV.fetch("CHAT_URI_BASE"),
  llm_options: {
    api_type:    :azure,
    api_version: ENV.fetch("API_VERSION")
  }
)

chroma = Langchain::Vectorsearch::Chroma.new(
  url:        ENV.fetch("CHROMA_URL"),
  index_name: "documents",
  llm:        llm
)

chroma.create_default_schema

docs = [
  Langchain.root.join("/path/to/guideline.pdf")
]

pp chroma.add_data(
  paths: docs
)

ドキュメントの検索と、ドキュメントを元にした回答を返すには下記のようになります。LLM のクラスが異なる以外は OpenAI の場合と同様です。

require "langchain"

llm = Langchain::LLM::Azure.new(
  api_key:                  ENV.fetch("AZURE_OPENAI_API_KEY"),
  embedding_deployment_url: ENV.fetch("EMBED_URI_BASE"),
  chat_deployment_url:      ENV.fetch("CHAT_URI_BASE"),
  llm_options: {
    api_type:    :azure,
    api_version: ENV.fetch("API_VERSION")
  }
)

chroma = Langchain::Vectorsearch::Chroma.new(
  url:        ENV.fetch("CHROMA_URL"),
  index_name: "documents",
  llm:        llm
)

pp chroma.similarity_search(
  query: "CISA",
  k:     1
)

response = chroma.ask(
  question: "ガイドラインの作成に関わった国はどこですか?"
)

pp response.raw_response.dig("choices", 0, "message", "content")

まとめ

langchain.rb は本家の LangChain と比べるとまだまだ差分がありますが、頻繁にアップデートが行われているので、 ruby-openai と同様に今後さらに充実してくるかと思います。 また、 langchain.rb では ActiveRecord をベクトル検索に利用することもできるようですが今回はそこまではやれなかったので、 Rails アプリでの活用なども今後試してみたいと思います。

ユニファではLLMの活用含めて共に保育をHackしてくれる仲間を募集中です!少しでも興味をお持ちいただけた方は、ぜひ一度カジュアルにお話しましょう!

unifa-e.com