ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

Azure OpenAI Service と Azure Cognitive Search を使って .NET アプリケーションからベクトル検索を試してみた

まだプレビューですが Azure Cognitive Search にベクトル検索機能が提供されました。これにより、Azure OpenAI Serivce で Embedding したベクトルを永続化するデータベースとして使うことができます。
今回は下記のリポジトリを参考にしながら、ベクトル検索を試してみました。
github.com

ベクトル検索とは

Azure OpenAI Service では、Embedding のモデル(text-embedding-ada-002)が提供されていて、会話でやりとりされる自然言語の文字列をベクトル(1536 個の浮動小数点数の高次元配列)に変換することができます。
Cognitive Search がベクトル検索に対応したことで、ベクトルを使った文字列の類似性の判断ができるので、キーワード検索では判断が難しい言い回しの違いやちょっとした誤字にとらわれずに検索できるようになります。ベクトル検索としては、文字列に限らず画像の類似検索にも使うことができます。
このあたりの話は、Microsoft の寺田さんの記事が分かりやすかったです。
qiita.com

事前準備

.NET アプリケーション

.NET 7 のコンソールアプリを作り、OpenAI と Cognitive Search の NuGet ライブラリをインストールします。ベクトル検索に対応したバージョンは、11.5.0-beta.4 になります。

Install-Package Azure.AI.OpenAI -Version 1.0.0-beta.6
Install-Package Azure.Search.Documents -Version 11.5.0-beta.4
Install-Package Microsoft.Extensions.Configuration.Binder -Version 7.0.4
Install-Package Microsoft.Extensions.Configuration.UserSecrets -Version 7.0.0

OpenAI と Cognitive Search に接続する資格情報を .NET の UserSecrets に定義します。Embedding には text-embedding-ada-002 のモデルを使います。

{
  "AzureOpenAISettings": {
    "CognitiveSearchEndpoint": "https://xxx.search.windows.net",
    "CognitiveSearchKey": "xxx",
    "CognitiveSearchIndexName": "kyogashi-vector-index",
    "Endpoint": "https://xxx.openai.azure.com",
    "ApiKey": "xxx",
    "DeploymentOrModelName": "text-embedding-ada-002"
  }
}

secret.json のフィールドをバインドするクラスを作ります。

internal class AzureOpenAISettings
{
    public required string CognitiveSearchEndpoint { get; set; }
    public required string CognitiveSearchKey { get; set; }
    public required string CognitiveSearchIndexName { get; set; }
    public required string Endpoint { get; set; }
    public required string ApiKey { get; set; }
    public required string DeploymentOrModelName { get; set; }
}

ConfigurationBuilder で secret.json を読み込んで AzureOpenAISettings クラスにバインドします。読み込んだ情報を使って、OpenAI と Cognitive Search に接続するクラスをインスタンス化しておきます。

var settings = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build()
    .GetSection(nameof(AzureOpenAISettings)).Get<AzureOpenAISettings>() ?? throw new NullReferenceException();

var openAIClient = new OpenAIClient(new Uri(settings.Endpoint), new AzureKeyCredential(settings.ApiKey));
var indexClient = new SearchIndexClient(new Uri(settings.CognitiveSearchEndpoint), new AzureKeyCredential(settings.CognitiveSearchKey));
var searchClient = indexClient.GetSearchClient(settings.CognitiveSearchIndexName);

サンプルデータ

ベクトル検索を試すデータは、ChatGPT を使ってサクッと京都の和菓子に関するデータ を作成しました。

[
  {
    "id": "1",
    "title": "花びら餅",
    "content": "花びら餅は、京都の伝統的な和菓子で、美しい花びら模様の薄い皮と甘さ控えめのあんこが特徴です。この和菓子は季節ごとにバリエーションが楽しめ、茶道やお茶うけに使われ、京都のお土産として人気があります。見た目も美しく、食べるだけでなく、芸術的な価値も持っています。また、花びら餅は季節感あふれる和菓子で、特に春に楽しまれます。春の季節になると、桜の花びらを使った花びら餅が登場し、桜の風味を楽しむことができます。この季節になると、花見のお供としても愛され、桜の美しさと和の風情を同時に楽しむことができます。花びら餅は、京都の伝統を受け継ぐ和菓子として、その美しさと風味によって多くの人に愛されています。",
    "category": ""
  },
  {
    "id": "2",
    "title": "椿餅",
    "content": "椿餅は、椿の花をイメージした和菓子で、もちもちの皮とこしあんが特徴です。この和菓子は見た目が美しく、和の風情を楽しむ品として知られています。椿餅の魅力は、その花のような形状と風味にあります。もちもちの皮は、食感を楽しむ要素として重要であり、こしあんの甘さとの組み合わせが絶妙です。この絶妙なバランスが、多くの人に喜ばれています。また、椿餅は春の季節に特に人気があります。椿の花が春に咲くことから、この季節になると椿餅が楽しまれ、春の訪れを感じることができます。椿餅は、見た目にも美しい和菓子で、和の文化を感じることができる逸品です。",
    "category": ""
  }
]

ベクトルデータベースを作成する

ベクトルの保存先として、Azure にはいくつかの選択肢があります。

  • Azure Database for PostgreSQL
  • Azure Cache for Redis
  • Azure Cosmos DB
  • Azure Cognitive Search

今回は Cognitive Search のインデックスを作成します。VectorSearch プロパティにはベクトル検索のアルゴリズムとして Hierarchical Navigable Small World(HNSW)を設定します。titlecontentcategory のフィールドには ja.lucene の日本語アナライザーを設定し、ベクトルは contentVector フィールドに格納します。

static SearchIndex CreateSearchIndex(string indexName)
{
    var vectorSearchConfigName = "vector-config";
    var modelDimensions = 1536;

    return new SearchIndex(indexName)
    {
        VectorSearch = new VectorSearch
        {
            AlgorithmConfigurations =
            {
                new HnswVectorSearchAlgorithmConfiguration(vectorSearchConfigName)
            }
        },
        Fields =
        {
            new SimpleField("id", SearchFieldDataType.String) { IsKey = true, IsFilterable = true, IsSortable = true, IsFacetable = true },
            new SearchableField("title") { IsFilterable = true, IsSortable = true, AnalyzerName = LexicalAnalyzerName.JaLucene },
            new SearchableField("content") { IsFilterable = true, AnalyzerName = LexicalAnalyzerName.JaLucene },
            new SearchField("contentVector", SearchFieldDataType.Collection(SearchFieldDataType.Single))
            {
                IsSearchable = true,
                VectorSearchDimensions = modelDimensions,
                VectorSearchConfiguration = vectorSearchConfigName
            },
            new SearchableField("category") { IsFilterable = true, IsSortable = true, IsFacetable = true, AnalyzerName = LexicalAnalyzerName.JaLucene }
        }
    };
}

サンプルデータの JSON から読み取ったデータをインデックスに登録します。contentVector フィールドには OpenAI でEmbedding したベクトルを格納します。

static async Task UploadDocumentAsync(string deploymentOrModelName, SearchClient searchClient, OpenAIClient openAIClient)
{
    var json = File.ReadAllText("./kyogashi.json");
    var sampleDocuments = new List<SearchDocument>();
    foreach (var document in JsonSerializer.Deserialize<List<Dictionary<string, object>>>(json) ?? new List<Dictionary<string, object>>())
    {
        var content = document["content"]?.ToString() ?? string.Empty;
        document["contentVector"] = (await GetEmbeddingsAsync(deploymentOrModelName, content, openAIClient)).ToArray();
        sampleDocuments.Add(new SearchDocument(document));
    }
    await searchClient.IndexDocumentsAsync(IndexDocumentsBatch.Upload(sampleDocuments));
}

static async Task<IReadOnlyList<float>> GetEmbeddingsAsync(string deploymentOrModelName, string text, OpenAIClient openAIClient)
{
    var response = await openAIClient.GetEmbeddingsAsync(deploymentOrModelName, new EmbeddingsOptions(text));
    return response.Value.Data[0].Embedding;
}

ベクトルデータベースを作成するコードを実装します。初回のみ作成すればいいので、選択できるようにしています。

Console.Write("Would you like to index (y/n)? ");
var indexChoice = Console.ReadLine()?.ToLower() ?? string.Empty;
if (indexChoice == "y")
{
    await indexClient.CreateOrUpdateIndexAsync(CreateSearchIndex(settings.CognitiveSearchIndexName));
    await UploadDocumentAsync(settings.DeploymentOrModelName, searchClient, openAIClient);
}

コードを実行すると、インデックスが作成されていることを確認できます。


ベクトル検索してみる

ベクトル検索を実行する VectorSearchAsync メソッドを作成します。パラメータで受け取った質問を OpenAI でEmbedding してクエリを実行します。KNearestNeighborsCount プロパティで検索結果上位3件を返す設定にしました。

static async Task VectorSearchAsync(SearchClient searchClient, OpenAIClient openAIClient, string deploymentOrModelName, string query)
{
    var queryEmbeddings = await GetEmbeddingsAsync(deploymentOrModelName, query, openAIClient);

    var searchOptions = new SearchOptions
    {
        Vectors = { new SearchQueryVector { Value = queryEmbeddings.ToArray(), KNearestNeighborsCount = 3, Fields = { "contentVector" } } },
        Size = 3,
        Select = { "title", "content", "category" },
    };
    SearchResults<SearchDocument> response = await searchClient.SearchAsync<SearchDocument>(null, searchOptions);

    var count = 0;
    await foreach (var result in response.GetResultsAsync())
    {
        count++;
        Console.WriteLine($"Title: {result.Document["title"]}");
        Console.WriteLine($"Score: {result.Score}");
        Console.WriteLine($"Content: {result.Document["content"]}");
        Console.WriteLine($"Category: {result.Document["category"]}\n");
    }
    Console.WriteLine($"Total Results: {count}");
}

ベクトル検索のメソッドを呼び出すコードを実装します。詳細は後述しますが、他の検索と比較するためのロジックもいれてあります。

var inputQuery = "暑い日に食べられている京菓子は?";
Console.WriteLine($"Query: {inputQuery}\n");

Console.WriteLine("Choose a query approach:");
Console.WriteLine("1. Vector Search");
Console.WriteLine("2. Keyword Search");
Console.WriteLine("3. Hybrid Search");
Console.Write("Enter the number of the desired approach: ");
var choice = int.Parse(Console.ReadLine() ?? "0");
Console.WriteLine("");

switch (choice)
{
    case 1:
        await VectorSearchAsync(searchClient, openAIClient, settings.DeploymentOrModelName, inputQuery);
        break;
    case 2:
        await KeywordSearchAsync(searchClient, inputQuery);
        break;
    case 3:
        await HybridSearchAsync(searchClient, openAIClient, settings.DeploymentOrModelName, inputQuery);
        break;
    default:
        Console.WriteLine("Invalid choice. Exiting...");
        break;
}

暑い日に食べられている京菓子を検索してみます。

「夏」というキーワードで質問していませんが、「暑い日」というテキストの類似性を見て、若鮎や水無月がヒットしています。求肥のはいった若鮎は美味しいですね。

キーワード検索と比較してみる

ベクトル検索の結果と比較してみたいので、シンプルなキーワード検索のメソッドを実装します。パラメータで受け取った質問をそのままクエリで実行しています。

static async Task KeywordSearchAsync(SearchClient searchClient, string query)
{
    var searchOptions = new SearchOptions
    {
        Size = 3,
        Select = { "title", "content", "category" },
    };
    SearchResults<SearchDocument> response = await searchClient.SearchAsync<SearchDocument>(query, searchOptions);

    var count = 0;
    await foreach (var result in response.GetResultsAsync())
    {
        count++;
        Console.WriteLine($"Title: {result.Document["title"]}");
        Console.WriteLine($"Score: {result.Score}");
        Console.WriteLine($"Content: {result.Document["content"]}");
        Console.WriteLine($"Category: {result.Document["category"]}\n");
    }
    Console.WriteLine($"Total Results: {count}");
}

同様に、暑い日に食べられている京菓子を検索してみます。

「暑い日」というキーワードがヒットする水無月がトップにきています。3位の粽は「食べられている」というキーワードに引っ張られたのかもしれません。

ハイブリッド検索と比較してみる

次にハイブリッド検索のメソッドを実装します。パラメータで受け取った質問を文字列と OpenAI でEmbedding したベクトルの両方をクエリで実行しています。

static async Task HybridSearchAsync(SearchClient searchClient, OpenAIClient openAIClient, string deploymentOrModelName, string query)
{
    var queryEmbeddings = await GetEmbeddingsAsync(deploymentOrModelName, query, openAIClient);

    var searchOptions = new SearchOptions
    {
        Vectors = { new SearchQueryVector { Value = queryEmbeddings.ToArray(), KNearestNeighborsCount = 3, Fields = { "contentVector" } } },
        Size = 3,
        Select = { "title", "content", "category" },
    };
    SearchResults<SearchDocument> response = await searchClient.SearchAsync<SearchDocument>(query, searchOptions);

    var count = 0;
    await foreach (var result in response.GetResultsAsync())
    {
        count++;
        Console.WriteLine($"Title: {result.Document["title"]}");
        Console.WriteLine($"Score: {result.Score}");
        Console.WriteLine($"Content: {result.Document["content"]}");
        Console.WriteLine($"Category: {result.Document["category"]}\n");
    }
    Console.WriteLine($"Total Results: {count}");
}

同様に、暑い日に食べられている京菓子を検索してみます。

若鮎、水無月という順番で、最も欲しかった検索結果を返してくれています。

まとめ

Azure OpenAI Service と Azure Cognitive Search を使って .NET アプリケーションからベクトル検索を試してみました。今回は割愛しましたが、フィルターを使って category を絞り込むことで、検索精度を上げることもできます。
難しいと感じたのは、ベクトル化する元になるデータの作り方(テキストの内容やチャンク分割)です。同じ質問を投げてもデータの内容次第で検索結果が変わってくるので、どの検索アルゴリズムが有効なのかも含めた検証が重要です。
また、Cognitive Search にはセマンティック検索という機能もあるので、次回に試してみたいと思います。

今回のサンプルアプリは、こちらで公開しています。
github.com

Microsoft MVP for Microsoft Azure を再受賞しました

2023年7月1日付けで、Microsoft Most Valuable Professional (MVP) アワードを再受賞しました。カテゴリは Microsoft Azure で、7 年目の受賞となります。


昨年の主な活動内容

JAZUG の登壇は 12周年総会で Azure Container Apps のライブデモ を行いました。スクラッチで .NET 7 の MVC アプリを作って、Container Apps で Blue - Green デプロイする内容でした。
gooner.hateblo.jp

クラウドデベロッパーちゃんねるでは、MS の寺田さんと Azure Container Apps のお話をしてきました。Microsoft Build 2022 で MVP パーソナルスポンサーとして提供したサンプルコードを紹介しました。
gooner.hateblo.jp

ブログでも、Azure Container Apps ネタが多めでした。
gooner.hateblo.jp

今年の活動目標

昨年10月から体調を崩してしまい、後半はコミュニティ活動できませんでした。だいぶ回復してきたので、少しずつにコミュニティ活動を再開していきたいです。
JAZUG は、オフラインとのハイブリッドで開催しているので、登壇だけでなく運営もお手伝いを再開していきたいです。
jazug.connpass.com

Azureもくもく会@新宿は、昨年後半から中断しているので、オフラインで再開できればと考えています。
azure-mokumoku.connpass.com

技術領域としては、Azure OpenAI Service のキャッチアップとアウトプットに注力したいです。OpenAI 関連の情報は非常に多いですが、.NET エンジニアの観点での情報を発信できたらと思います。

Azure OpenAI Service の Function Calling を .NET アプリケーションから使ってみた

まだプレビューですが、Azure OpenAI Service で Function Calling が使えるようになりました。今回は .NET エンジニアの視点で Function Calling を使ってみました。
techcommunity.microsoft.com

Function Calling とは

OpenAI を業務で活用しようとすると、外部システムの API と連携させたいケースが出てきます。チャットの会話でやりとりされる自然言語をもとに、呼び出したい API を特定したり、パラメータで渡すデータを生成したくなりますが、プロンプトエンジニアリングを試行錯誤する必要があり難易度が高い実装でした。

Function Calling を使うと、自然言語の中から API を呼び出すための情報を抽出してくれるので、OpenAI と外部システムを容易に連携させることができるようになります。
zenn.dev

Function Calling を使ってみる

紅葉の名所を探すのを手伝ってくれる AI アシスタントを作ります。この AI アシスタントは、「京都の紅葉」のように会話から指定された場所の名所を教えてくれます。

このシナリオを Azure OpenAI Client Library を使って実装してみます。.NET 7 のコンソールアプリを作る前準備は、以前の記事を参照してください。
モデルは、gpt-4 を使います。なお、SDK で Function Calling に対応しているバージョンは 1.0.0-beta.6 となります。
gooner.hateblo.jp
紅葉の名所を検索する API は、京都の清水寺で決め打ちの関数を定義します。

internal class ApiClient
{
    public static string SearchAutumnLeaves(string location)
    {
        // 本当は location を使って検索するが、今回は京都の名所を決め打ちで返す。
        return "{\n  \"result\": \"清水寺\",\n}";
    }
}

internal class AutumnLeavesParameter
{
    public required string Location { get; set; }
}

システムロールにアシスタントのコンテキストと Function を定義します。ChatCompletionsOptions クラスに Functions プロパティが追加されているので、FunctionDefinition クラスで Name、Description、Parameters を指定できます。

var chatCompletionsOptions = new ChatCompletionsOptions
{
    MaxTokens = 800,
    Messages =
    {
        new ChatMessage(ChatRole.System, """
            あなたは、ユーザーが紅葉の名所を検索するのを助けるために設計されたAIアシスタントです。
            ユーザーから紅葉の名所を質問されたら、search_autumn_leaves関数を呼び出します。
        """)
    },
    Functions =
    {
        new FunctionDefinition{
            Name = "search_autumn_leaves",
            Description = "引数で指定された場所の紅葉の名所を検索します。",
            Parameters = BinaryData.FromObjectAsJson(new
            {
                Type = "object",
                Properties = new
                {
                    location = new
                    {
                        Type = "string",
                        Description = "紅葉の名所を検索する場所、場所には都道府県や都市の名前を含めること、例:東京、山梨",
                    },
                },
                Required = new[] { "location" },
            },
            new JsonSerializerOptions
            {
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            }),
        }
    },
    FunctionCall = FunctionDefinition.Auto
};

Console.WriteLine("アシスタントのセットアップ中・・・");
var client = new OpenAIClient(new Uri(settings.Endpoint), new AzureKeyCredential(settings.ApiKey));
await client.GetChatCompletionsAsync(settings.DeploymentName, chatCompletionsOptions);

ユーザーロールには、こちらからの送信メッセージを定義します。

Console.WriteLine("チャットを開始する");
var userMessage = "今年は紅葉を見に行きたいけど、京都でお勧めの紅葉の名所を教えてください。";
Console.WriteLine($"{ChatRole.User}: {userMessage}");
chatCompletionsOptions.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
var result = await client.GetChatCompletionsAsync(settings.DeploymentName, chatCompletionsOptions);
var choice = result.Value.Choices[0];

レスポンスの FinishReason が「function_call」の場合、紅葉の名所を検索する API を呼び出します。Function の Name と Arguments をいかに期待通りに取得できるかが肝となるので、

  • システムロールのコンテキスト
  • Functions や Parameter の Description

を明確かつ詳細に定義する必要があります。
Function Calling として必須ではありませんが、紅葉の名所は API から JSON 形式で返されるため、チャットで返すことに適した自然な文章を生成しています。

if (choice.FinishReason == "function_call")
{
    // API呼び出し
    var functionsResponse = String.Empty;
    switch (choice.Message.FunctionCall.Name)
    {
        case "search_autumn_leaves":
            var parameter = JsonConvert.DeserializeObject<AutumnLeavesParameter>(choice.Message.FunctionCall.Arguments);
            if (parameter == null)
            {
                Console.WriteLine("FunctionCall.Arguments is null");
            }
            else
            {
                functionsResponse = ApiClient.SearchAutumnLeaves(parameter.Location);
            }
            break;
        default:
            Console.WriteLine("function: not found");
            return;
    }

    // API のレスポンスを使ってメッセージを返す
    chatCompletionsOptions.Messages.Add(
        new ChatMessage
        {
            Role = choice.Message.Role,
            Name = choice.Message.FunctionCall.Name,
            Content = choice.Message.FunctionCall.Arguments
        });
    chatCompletionsOptions.Messages.Add(
        new ChatMessage
        {
            Role = ChatRole.Function,
            Name = choice.Message.FunctionCall.Name,
            Content = functionsResponse
        });
    var assistantResult = await client.GetChatCompletionsAsync(settings.DeploymentName, chatCompletionsOptions);
    var assistantChoice = assistantResult.Value.Choices[0];
    Console.WriteLine($"{assistantChoice.Message.Role}: {assistantChoice.Message.Content}");

    Console.WriteLine("---------------------------------");
    Console.WriteLine("Information");
    Console.WriteLine($"- FunctionCall.Name: {choice.Message.FunctionCall.Name}");
    Console.WriteLine($"- FunctionCall.Arguments: {choice.Message.FunctionCall.Arguments}");
}
else
{
    Console.WriteLine($"{choice.Message.Role}: {choice.Message.Content}");
}

チャットの質問に対して、期待通りの結果を取得できたことが分かります。

紅葉の名所を検索する場所が質問に含まれていないと場所を聞き返してくれるので、その後の会話で Funcion を呼び出すこともできます。
呼び出す Function が特定できないケースでは、FinishReason に「stop」が格納されています。

なお、まったく関係のない質問をすると、下記のように回答されます。


まとめ

Azure OpenAI Service で Function Calling を試してみました。チャットの会話でやりとりされる自然言語をもとに外部システムの API と連携させることが容易になるので、OpenAI 活用の幅が広がる機能ですし、プロンプトエンジニアリングの手法のひとつの ReAct をいい感じで実装できます。

Function Calling のリリースドキュメントに記載がありますが、関数の呼び出しだけでなく、JSON などの構造化されたモデルを生成する用途にも使えそうだと感じました。

今回のサンプルアプリは、こちらで公開しています。
github.com