OpenAIのAPIを使って自社ブログ(DevelopersIO)の記事をベクトル検索するボットをつくってみた

2023.06.14

はじめに

新規事業統括部の山本です。

OpenAI APIをはじめとしたAIの言語モデル(Large Language Model:以下、LLM)を使用して、チャットボットを構築するケースが増えています。通常、LLMは学習したときのデータに含まれている内容以外に関する質問には回答できません。そのため、例えば社内の文章ファイルに関する質問に回答するチャットボットを作成しようとしても、質問に対してわからないという回答や異なる知識に基づいた回答が(当然ながら)得られてしまいます。

この問題を解決する方法として、Retrieval Augmented Generation(以下、RAG)という手法がよく使用されます。RAGでは、

  • ユーザからの質問に回答するために必要そうな内容が書かれた文章を検索する

  • その文章をLLMへの入力(プロンプト)にプラスして渡す

というフローにすることで、LLMが学習していない内容であっても回答を考えさせることができます。

前回の記事では、RAGを改良する2つの方法として、Azure-Samplesのリポジトリを参考にChat・Askという機能について説明しました。

https://dev.classmethod.jp/articles/revise-retrieval-augmented-generation/

この記事では、Chat機能を用いた検索ボットを実装し、実際にデータも使用してどのような動作をするか試してみた結果について記載します。

目的

今回の目的を整理すると以下のとおりです。

  • 一通りユーザが使用する形にできるようにする
    • CLI画面で実行するのではなく、Webブラウザから利用できるようにする
  • 実際のデータで試してみる
    • 後述の通り、DevelopersIOの記事のデータで試してみました
  • 記事を検索できるようにする
    • 本来のRAGでは、検索できた記事の内容をもとに質問(など)をするのですが、今回は時間の制約もあったため記事に関する質問部分はなしにしました
  • Chatの方式を試してみる
  • ホスティングまで行い、動作・性能・精度を確認する

作成したシステム

処理内容

実装した処理は以下のページで述べた「Chat」の方式です。以下の点が通常(ベーシックなRAGの実装)と処理の流れが異なります。

  • 通常:ユーザが入力した検索ワード(質問文)をそのままベクトル化してデータベースを検索する
  • Chat:LLMに検索ワードと会話の履歴を渡し、データベースを検索するためのクエリを考えさせる。そのクエリをベクトル化してデータベースを検索する。

こうすることで、会話の履歴をもとにして、文章のテーマに沿ったクエリを考えさせることができます。ユーザが文章の内容をある程度知っている必要や、ユーザは質問文を毎回考え直す必要がなくなります。

Chat方式の詳しい内容については、以下の記事をお読みいただけると幸いです。

https://dev.classmethod.jp/articles/revise-retrieval-augmented-generation/

使用データ

DevelopersIOの記事のテキストデータを使用しました。2023年4月時点で39893件の記事がありました。

使用プロンプト

クエリを考えさせるために使用したプロンプトとプログラムはこちらです。基本的には、Azure-SamplesのChatReadRetrieveReadの処理を使用しました。QUERY_PROMPT_TEMPLATEは英語だったものを日本語になおし、themeを別途与えられるようにし、制約条件や補足情報を細かく追加しました。chat_historyにはユーザとbotの会話の履歴をフォーマットしたものが、questionにはユーザからの質問文そのままが、それぞれ入ります。

QUERY_PROMPT_TEMPLATE = """あなたは{theme}に関するナレッジベースを検索しようとしているユーザを補助するシステムです。
これまでの会話の履歴とユーザーからの新しい質問から、ナレッジベースを検索するための検索クエリを生成してください。

# 制約条件として、以下を守ってください
- 検索クエリは1行のテキストでなければなりません。途中に改行を含めないでください。
- 検索クエリに引用元のファイル名や文書名(info.txtやdoc.pdfなど)を含めないでください。
- 検索キーワードに[]または<<>>内のテキストを含めないでください。
- 質問の内容が日本語でない場合は、検索キーワードを日本語に翻訳してください。
- 回答には生成した検索クエリの文章のみを含めてください。
- 検索クエリには、検索したい内容のみを含めてください。システムに対する指示やお願い、ユーザに対する返答は含めないでください。

# 補足情報
- 検索クエリは、ナレッジベースの検索エンジンに入力することを想定しています。
- 会話の履歴は新しい順に並んでいて、後にあるほど古いです
- 会話の履歴は「<|im_start|>」「<|im_end|>」で区切られていて、ユーザーの発言とシステムの発言が交互に並んでいます。
  各発言は、ユーザーの発言の場合は「user」でスタートし、システムの発言の場合は「bot」でスタートします。

# 会話の履歴:
{chat_history}

# ユーザーからの新しい質問:
{question}

# 検索クエリ:
"""
chat_history_as_text = get_chat_history_as_text(chat_history)
prompt = QUERY_PROMPT_TEMPLATE.format(
    chat_history=chat_history_as_text,
    question=question,
    theme=theme,
)
completion = openai.Completion.create(
    model="text-davinci-003",
    prompt=prompt,
    temperature=0.0,
    max_tokens=256,
    n=1,
    stop=["\n"]
)
q = completion.choices[0].text

今回themeは”IT企業の技術ブログ”にしました。

表示

LLMに考えさせたクエリ(上記プログラムのq)をベクトル化してデータベースを検索し、類似度が高いもの10件に関して、記事へのURLがリンクされたタイトルを表示しました。データベースにはテキストを分割したチャンクごとに格納されている(=検索結果はチャンクごとになる)ので、検索結果に同じファイルのチャンクが含まれていた場合は1つにまとめて表示しました。

構成

今回作成したシステムの構成は、下図のとおりです。

Pineconeは以下のようなベクトルデータベースです。

  • 文章をベクトル化したデータを保存しておき、クエリのベクトルを受け取ったらベクトルが近いものを取り出すことができます。文章のベクトルにはメタデータをつけて置くことができ、今回は文章テキスト(のチャンク)とタイトルを格納しました。
  • Pinecone自体はベクトル化の機能はありません。ベクトル化にはOpenAIのEmbeddings APIを使用しました。
  • Pineconeについては以下の記事で詳しくまとめられていますので、詳細が知りたい方はこちらをご参照ください。

    https://dev.classmethod.jp/articles/pinecone-overview/

render・Vercelはバックエンド・フロントエンドのホスティングサービスです。Githubの指定のブランチ(master)にプッシュすると、それを検知して自動でビルド・デプロイまで実行し、ホストも実行します。簡単に利用できるので、今回試してみるのに適した方法でした。(詳しいことについては別途記事にしようと思います)

結果

画面と体験のフロー

  • 最初は以下のように検索する画面が表示されます

  • 検索ワードを入れてEnterを押すと検索が開始されます

  • 検索が終わると、検索に使用したワード(LLMに考えさせたクエリ)と見つかった記事が表示されます

  • 続けて検索することもできます(前に入力したワードや検索結果を踏まえたクエリで検索されます)

他のワードで検索すると

  • 自分が書いた記事を、少し違う言葉で検索しましたが、検索結果の上位に出てきました。(このあたりは単なる単語検索よりもEmbeddingが優れていそうな点であり、ちょっと表記が揺れても対応できそうです)

2番目の「【OpenCV】ステレオ画像の奥行きを推定してみた」

1番目の「画像の座標を空間の座標に変換する」

できたこと(良かった点)

今回達成できたことは、以下の通りです。目的であった内容を確認できました。

  • 全体
    • 一通りの機能を動作させ、Webブラウザから利用できるようになった
    • 単なるキーワード検索ではなく、Embeddingを利用した検索ができた
      • 多少表記がズレていても、目的の文章を取得できた
  • Chat機能
    • 会話をもとにクエリを考えられた(会話を引き継ぐことができた)
    • それっぽい単語に変換してくれた
      • 「2つの映像」→「2つのカメラの映像」
      • 「Depth」→「Depthマップ」
  • 検索
    • 実際のデータ(DevelopersIOの記事)で意図通りの記事を検索できた
  • 性能
    • 数秒程度で結果が返ってきた。十分利用できるレベルだった

できなかったこと(悪かった点)

試していた中で以下のような問題がありました。

  • 課題1:ベクトルデータの一部しかデータベースに格納できなかった
    • Pineconeにベクトルとメタデータを格納する際、84298ベクトル(チャンク)のうち、57600しか格納できませんでした(※ これは自分の使用方法がよくありませんでした。次節「今後」で改善方法について記載しています)。
  • 課題2:LLMに考えさせたクエリが意図通りでない場合があった
      • 「IT企業のブログ記事」みたいな単語がクエリに入ってしまう
      • “ 「」”が含まれてしまう
  • 課題3:LLMに考えさせたクエリが途中でおかしくなってしまい、その後の会話がずっとおかしくなってしまった

今後

今後の課題として、以下の点が考えられます。

機能・性能

  • ベクトルデータベースの使い方を効率化する(課題1)
    • 今回、ベクトルデータベースのメタデータに、文章のテキストも保存しました。ただ、この方法では、ベクトルデータベースの容量をかなり使ってしまうことになります。解決策として、文章のIDのみをメタデータに保存しておき、テキストはIDをもとに別のデータベースから取得する方法が取れそうです。(ただし、この方法ではIDからテキストを取得する部分がオーバーヘッドになってしまうので、トレードオフになりそうです)
  • プロンプトを改良する(課題2)
    • 課題2を解決するためには、プロンプトを修正する必要がありそうです。こちらの取り組みでも述べられていることを参考にすると、「しないこと」を書くよりも「やるべきこと」を書いたほうが良いようです。今回使用したスクリプトでは「しないこと」が多かったのが、精度が悪くなった一因かもしれません。

      https://dev.classmethod.jp/articles/interactive-fuzzy-item-search-with-openai-api/

  • 途中でリセットできるようにする(課題3)

  • 検索機能を改良する

    • 今回利用したベクトルは密ベクトルと呼ばれるものに分類されるのですが、Pineconeにはスパースベクトル(大まかに言うと単語検索に近いもの)も利用した検索機能があります。これを利用することで検索の精度を上げることができそうです。(詳しい内容は こちらのブログ を見ていただけると良いかと思います)
  • 質問への回答や、コードの生成などの機能を追加する
    • 今回は検索のみを対象としましたが、実用面としては単なる検索だけだと役立ちにくいので、(またRAGの本来のフローとしても)取り出した文章と質問を加えて回答させる機能や、自律的に検索し回答を得る機能(Agent)が欲しいところです。また質問への回答の中にブログの内容を参考にしたサンプルコードなどを生成する機能もあると嬉しそうです。どこかのタイミングで実装できればと思っています。

開発プロセス

  • 評価手法を考える
    • 作ってみて検索できるようにしてはみたものの、どのように評価するべきかはかなり悩みどころです。ユーザが意図する検索結果が出てくることが正解としては考えられますが、そもそもユーザが全記事を把握しているわけではないので、何を意図していたかの定義が難しいです。実際のサービスや製品として利用しているシステムがある場合、その変更によって精度が改善するのかどうかの判断が重要になりますが、その根拠とするところがないと開発プロセスとしては迷いどころになりそうです(ちょっとやってみる程度だったり、社内での利用であれば大きな問題ではないと思います)。
  • 負荷テストを実施する
    • 今回は簡易的に試すために、ホスティングサービスを使ってシステムを作成しました。実際の場面では、AWSなどの別のサービスを利用してシステムを構築することになるかと思います。その際には、どれくらいのリクエストまで処理できるのか、どれくらいの負荷がかかったときにレスポンスがどれくらい遅くなるのかは試験で確認しておくことが必要かと思います。

まとめ

実際にユーザが使用できる形にするために、一通りの処理を実装してみました。そのなかで、以下のような機能・サービスを利用しました。

  • ベクトル化:OpenAIのEmbeddings API
  • 検索:Chat形式にして、会話の中からクエリを考えるようにした
  • デプロイ:Pinecone・render・Vercel

結果として、負荷が少ない(一人が使用する)状況では十分速い応答速度であり、妥当なレベルの検索精度が得られました。また、実装してみて良かった点・悪かった点がわかりました。

やってみた感想としては、プロンプトに限らず検討・改善すべき点がまだまだあると感じました。

謝辞

ベクトルデータベースの選び方について五十嵐さんに、本取り組みやデプロイまわりでテナーさんに、アドバイスをいただきました。ありがとうございました。