GoでLLM搭載アプリケーションを構築する

Building LLM-powered applications in Go by Eli Bendersky

この1年でLLM(大規模言語モデル)や、埋め込みモデルといった周辺ツールの能力が大きく向上したことにより、 LLMを自分たちのアプリケーションに統合しようと考える開発者が増えてきました。

LLMは専用のハードウェアと多大な計算資源を必要とすることが多いため、たいていはAPIを提供するネットワークサービスとして パッケージ化されます。これはOpenAIやGoogle Geminiのような主要なLLMのAPIの仕組みそのものです。 Ollama のような自前でLLMを動かすためのツールでさえ、ローカルで使えるようにLLMをREST APIで ラップしています。さらに、アプリケーションでLLMを活用する開発者は、ベクトルデータベースのような補助的なツールも 必要とすることが多く、こうしたツールもたいていネットワークサービスとしてデプロイされます。

つまり、LLM搭載アプリケーションは他の現代的なクラウドネイティブアプリケーションととてもよく似ているということです。 RESTやRPCのプロトコル、並行性、パフォーマンスに対する優れたサポートが必要とされます。そしてこれらはまさにGoが得意とする 分野であり、GoをLLM搭載アプリケーションを書くのに最適な言語にしています。

この記事では、GoでシンプルなLLM搭載アプリケーションを作る例を紹介します。まずデモアプリケーションが解決する問題を説明し、 その後、すべて同じタスクを達成しながらも異なるパッケージで実装したアプリケーションのいくつかのバリエーションを紹介します。 この記事のデモのコードはすべてオンラインで入手できます

Q&AのためのRAGサーバー

LLM搭載アプリケーションでよく使われる手法の一つがRAG、すなわち 検索拡張生成(Retrieval Augmented Generation)です。 RAGは、特定のドメインでのやり取りのためにLLMの知識ベースをカスタマイズする、最もスケーラブルな方法の一つです。

Goで RAGサーバー を構築してみましょう。これはユーザーに次の2つの操作を提供するHTTPサーバーです。

  • 知識ベースにドキュメントを追加する
  • このドキュメントについてLLMに質問する

典型的な現実世界のシナリオでは、ユーザーはドキュメント群をサーバーに追加し、それから質問をしていきます。 たとえば、ある企業が社内ドキュメントをRAGサーバーの知識ベースに詰め込み、社内ユーザーにLLM搭載のQ&A機能を 提供する、といった使い方が考えられます。

サーバーと外の世界との相互作用を示した図がこちらです。

RAGサーバーの図

上で説明した2つの操作、すなわちユーザーがHTTPリクエストを送信することに加えて、サーバーは次のものともやりとりします。

  • 送信されたドキュメントおよびユーザーの質問についてベクトル埋め込みを 計算するための埋め込みモデル
  • 埋め込みの保存と検索を効率的に行うためのベクトルデータベース
  • 知識ベースから収集したコンテキストにもとづいて質問するためのLLM

具体的には、サーバーはユーザー向けに2つのHTTPエンドポイントを公開します。

/add/: POST {"documents": [{"text": "..."}, {"text": "..."}, ...]}:一連のテキストドキュメントをサーバーに送信し、 知識ベースに追加してもらいます。このリクエストに対して、サーバーは次のことを行います。

  1. 埋め込みモデルを使って各ドキュメントのベクトル埋め込みを計算する
  2. ドキュメントとそのベクトル埋め込みをベクトルDBに保存する

/query/: POST {"content": "..."}:質問をサーバーに送信します。このリクエストに対して、サーバーは次のことを行います。

  1. 埋め込みモデルを使って質問のベクトル埋め込みを計算する
  2. ベクトルDBの類似検索を使って、知識データベースの中からその質問に最も関連するドキュメントを見つける
  3. 単純なプロンプトエンジニアリングを使って、(2)で見つけた最も関連するドキュメントをコンテキストとして質問を再構成し、 LLMに送信して、その回答をユーザーに返す

このデモで使うサービスは次の通りです。

  • LLMと埋め込みモデルにはGoogle Gemini APIを使う
  • ローカルでホストするベクトルDBにはWeaviateを使う。WeaviateはGoで 実装されたオープンソースのベクトルデータベースです

これらを別の同等のサービスに置き換えるのはとても簡単なはずです。実際、これこそが2番目と3番目のサーバーバリエーションの テーマです!まずは、これらのツールを直接使う1番目のバリエーションから始めましょう。

Gemini APIとWeaviateを直接使う

Gemini APIとWeaviateにはどちらも便利なGo SDK(クライアントライブラリ)があり、最初のサーバーバリエーションは これらを直接使います。このバリエーションの完全なコードはこのディレクトリにあります。

この記事ではコード全体は掲載しませんが、読む際に留意しておくとよい点をいくつか挙げておきます。

構造:コードの構造は、Goで書かれたHTTPサーバーを書いたことがある人ならおなじみのものでしょう。 GeminiとWeaviate用のクライアントライブラリが初期化され、それらのクライアントはHTTPハンドラーに渡される 状態値の中に保存されます。

ルートの登録:このサーバーのHTTPルートは、Go 1.22で導入されたルーティングの強化を 使えば簡単に設定できます。

mux := http.NewServeMux()
mux.HandleFunc("POST /add/", server.addDocumentsHandler)
mux.HandleFunc("POST /query/", server.queryHandler)

並行性:このサーバーのHTTPハンドラーはネットワーク越しに他のサービスへアクセスし、応答を待ちます。 これはGoにとっては問題になりません。なぜなら各HTTPハンドラーはそれぞれ独立したゴルーチンで並行に実行されるからです。 このRAGサーバーは大量の並行リクエストを処理でき、それぞれのハンドラーのコードは線形的で同期的なままです。

バッチAPI/add/ リクエストは知識ベースに追加する大量のドキュメントを渡す可能性があるため、 このサーバーは埋め込み(embModel.BatchEmbedContents)とWeaviate DB(rs.wvClient.Batch)の両方について 効率化のために バッチAPI を活用しています。

Go向けLangChainを使う

2番目のRAGサーバーバリエーションは、LangChainGoを使って同じタスクを達成します。

LangChain はLLM搭載アプリケーションを構築するための人気のPythonフレームワークです。 LangChainGo はそのGo版に相当します。このフレームワークには、 モジュール化されたコンポーネントからアプリケーションを組み立てるためのツールが用意されており、多くのLLMプロバイダーと ベクトルデータベースを共通のAPIでサポートしています。これにより開発者は、任意のプロバイダーで動作し、 プロバイダーの切り替えも容易に行えるコードを書けるようになります。

このバリエーションの完全なコードはこのディレクトリにあります。 コードを読むと2つのことに気づくでしょう。

まず、前のバリエーションよりもいくらか短いという点です。LangChainGoがベクトルデータベースのAPI全体を 共通のインターフェースにラップしてくれるため、Weaviateを初期化し扱うためのコードが少なくて済みます。

次に、LangChainGoのAPIによってプロバイダーの切り替えがかなり簡単になっているという点です。仮にWeaviateを 別のベクトルDBに置き換えたいとしましょう。先ほどのバリエーションでは、ベクトルDBとやりとりするコードすべてを 新しいAPI用に書き直す必要がありました。LangChainGoのようなフレームワークを使えば、もはやそうする必要はありません。 LangChainGoが望みの新しいベクトルDBをサポートしている限り、すべてのDBが次のような 共通インターフェースを 実装しているので、サーバー内のほんの数行を置き換えるだけで済むはずです。

type VectorStore interface {
    AddDocuments(ctx context.Context, docs []schema.Document, options ...Option) ([]string, error)
    SimilaritySearch(ctx context.Context, query string, numDocuments int, options ...Option) ([]schema.Document, error)
}

Go向けGenkitを使う

今年の初め、GoogleはGenkit for Goを 発表しました。LLM搭載アプリケーションを構築するための新しいオープンソースフレームワークです。GenkitはLangChainといくつかの 特徴を共有していますが、他の側面では異なっています。

LangChainと同様に、Genkitも異なるプロバイダー(プラグインとして)が実装できる共通インターフェースを提供しており、 そのため一方から他方への切り替えがより簡単になっています。しかし、Genkitは異なるLLMコンポーネントがどのように 相互作用すべきかを規定しようとはしません。代わりに、プロンプトの管理やエンジニアリング、統合された開発者向けツールでの デプロイといった、プロダクションで役立つ機能に焦点を当てています。

3番目のRAGサーバーバリエーションは、Go向けGenkitを使って同じタスクを達成します。完全なコードは このディレクトリにあります。

このバリエーションは、LangChainGoのバリエーションとかなり似ています。プロバイダー固有のAPIを直接使うのではなく、 LLM、埋め込み器、ベクトルDBに対して共通のインターフェースが使われており、一方から他方への切り替えを容易にしています。 加えて、Genkitを使うとLLM搭載アプリケーションをプロダクションにデプロイするのがずっと簡単になります。 今回のバリエーションではこれを実装していませんが、興味があれば ドキュメントを読んでみてください。

まとめ

この記事のサンプルは、GoでLLM搭載アプリケーションを構築する際に可能なことのほんの一端を示したにすぎません。 比較的少ないコードで強力なRAGサーバーを構築できることを示しましたが、何より、Goのいくつかの基本的な機能のおかげで、 これらのサンプルはかなりの水準のプロダクションレディネスを備えています。

LLMサービスを扱うということは、たいていの場合、ネットワークサービスにRESTやRPCのリクエストを送信して応答を待ち、 その結果にもとづいて別のサービスに新しいリクエストを送信する、といったことの繰り返しを意味します。Goはこうした すべてに秀でており、並行性の管理や、複数のネットワークサービスを扱う複雑さに対応するための優れたツールを 提供しています。

さらに、クラウドネイティブな言語としてのGoの優れたパフォーマンスと信頼性は、LLMエコシステムのより基盤的な部分を 実装するための自然な選択肢にもなっています。たとえば OllamaLocalAIWeaviateMilvus といったプロジェクトが挙げられます。

By Eli Bendersky