🎉

.NET から Cognitive Search のベクトル検索を試そう

2023/08/15に公開

Azure Cognitive Search のプレビュー機能にベクトル検索があります。
今流行りの Azure OpenAI Service の text-embbeding-ada-002 を使ってベクトル化したデータを使って検索するやつですね。
ベクトル化したデータを保存しておいて検索できるサービスは色々あります。MS の寺田よしおさんが Azure OpenAI Embedding モデルを利用し最も関連性の高いドキュメントを見つける方法 の記事で PostgreSQL を使った方法を書いてくれています。それ以外のベクトル検索の部分もわかりやすいので、そちらの記事も是非見てみてください。

ということで、私は似たようなことを Cognitive Search を使ってやってみようと思います。お約束ですが、この記事を書いている時点ではプレビュー版の機能になります。そのため一般提供開始時には、ここで書いた内容は変わっている可能性があります。

テストデータを用意しよう

こういうことをやる時に一番めんどくさいのがテストデータの準備です…。今回は Azure OpenAI Service を使いプログラムでテストデータを作りました。
適当なコンソールアプリのプロジェクトを作成して、ユーザーシークレットに以下の情報を追加します。

secrets.json
{
  "OpenAI": {
    "Endpoint": "https://リソース名.openai.azure.com/",
    "ApiKey": "秘密のAPIキー",
    "ModelName": "gpt-35-turboのモデルのデプロイ名"
  }
}

そして Program.cs に以下のコードを書きます。Azure のサービス名を Azure OpenAI Service に渡して概要を生成してもらっています。

Program.cs
using Azure;
using Azure.AI.OpenAI;
using Microsoft.Extensions.Configuration;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

var c = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();

var options = c.GetSection("OpenAI")
    .Get<OpenAIOptions>()
    ?? throw new InvalidOperationException();

var client = new OpenAIClient(
    new Uri(options.Endpoint),
    new AzureKeyCredential(options.ApiKey));

var serviceNames = new[]
{
    "CosmosDB", "Azure Functions", "App Service", "SQL Database",
    "Azure Kubernetes Service", "Azure Container Instances", "Azure Container Registry",
    "Azure DevOps", "Azure Pipelines", "Azure Boards", "Azure Repos", "Azure Artifacts",
    "Azure Test Plans", "Azure Monitor", "Azure Application Insights", "Azure Log Analytics",
    "Azure Resource Manager", "Azure Resource Graph", "Azure Policy", "Azure Blueprints", "Azure Cost Management",
    "Azure Advisor", "Azure Security Center", "Azure Sentinel", "Azure Defender", "Azure Key Vault",
};

const string systemPrompt = """
    あなたはMicrosoft Azureの専門家として、ユーザーが指定したAzureサービスの概要を100文字以上、500文字以内で解説してください。
    """;

var results = new List<ServiceDescription>();
foreach (var serviceName in serviceNames)
{
    Console.WriteLine($"サービス名: {serviceName}");
    var chatCompletionsOptions = new ChatCompletionsOptions
    {
        ChoiceCount = 1,
        MaxTokens = 1000,
        Temperature = 0.0f,
        Messages =
        {
            new ChatMessage(ChatRole.System, systemPrompt),
            new ChatMessage(ChatRole.User, serviceName),
        },
    };

    var response = await client.GetChatCompletionsAsync(
        options.ModelName,
        chatCompletionsOptions);

    var choice = response.Value.Choices[0];
    if (choice.FinishReason != CompletionsFinishReason.Stopped)
    {
        Console.WriteLine($"何かエラー: {choice.FinishReason}");
        return;
    }

    results.Add(new()
    {
        ServiceName = serviceName,
        Description = choice.Message.Content,
    });
}

using var file = File.OpenWrite("services.json");
await JsonSerializer.SerializeAsync<List<ServiceDescription>>(
    file,
    results,
    new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
    });
await file.FlushAsync();

class OpenAIOptions
{
    public required string Endpoint { get; set; }
    public required string ApiKey { get; set; }
    public required string ModelName { get; set; }
}

class ServiceDescription
{
    public string ServiceName { get; set; } = "";
    public string Description { get; set; } = "";
}

途中にある Azure のサービス名の配列は GitHub Copilot に生成してもらいました。AI 様々です。
なので、一部ディスコンのサービス名が入っていますが、気にせずに今回はやっていこうと思います。

実行すると、以下のような JSON ファイルが出来上がりました。いい感じ。

[
  {
    "serviceName": "CosmosDB",
    "description": "Cosmos DBは、Microsoft Azureのグローバルに分散されたマルチモデルデータベースサービスです。スケーラビリティ、可用性、パフォーマンスに優れ、JSONドキュメント、キーバリュー、列指向、グラフデータモデルをサポートしています。また、多くのAPI(SQL、MongoDB、Cassandra、Gremlinなど)を提供し、開発者が最適な方法でデータを操作できます。さらに、グローバルにレプリケーションされたデータベースを簡単に作成し、データの一貫性と可用性を確保できます。"
  },
  {
    "serviceName": "Azure Functions",
    "description": "Azure Functionsは、イベント駆動型のサーバーレスコンピューティングプラットフォームです。コードを書いてデプロイするだけで、自動的にスケーリングされる関数を作成できます。トリガーに応じて関数が実行され、処理が完了すると自動的に停止します。これにより、アプリケーションの開発と実行に必要なリソースを最小限に抑えることができます。また、多くのプログラミング言語と統合されており、さまざまな用途に柔軟に対応できます。"
  },
  {
    "serviceName": "App Service",
    "description": "App Serviceは、クラウド上でWebアプリケーションやモバイルバックエンドを簡単に構築、デプロイ、スケールするためのサービスです。自動的なスケーリングや高可用性、セキュリティ機能を提供し、複数のプログラミング言語やフレームワークに対応しています。また、CI/CDパイプラインの統合やモニタリング、トラブルシューティングの機能も備えており、開発者にとって効率的な開発環境を提供します。"
  },
  {
    "serviceName": "SQL Database",
    "description": "Azure SQL Databaseは、クラウドベースのリレーショナルデータベースサービスです。高い可用性、スケーラビリティ、セキュリティを提供し、アプリケーションのパフォーマンスを向上させます。データのバックアップ、復元、監視も簡単に行えます。また、柔軟な料金体系と自動チューニング機能も備えています。開発者や企業にとって、簡単かつ効率的なデータベース管理のための理想的な選択肢です。"
  },
  {
    "serviceName": "Azure Kubernetes Service",
    "description": "Azure Kubernetes Service (AKS)は、マイクロサービスアプリケーションをデプロイ、管理するためのマネージドKubernetesサービスです。AKSは、高可用性、スケーラビリティ、セキュリティを提供し、コンテナ化されたアプリケーションのデプロイを簡素化します。また、AKSはAzureの他のサービスとのシームレスな統合を提供し、DevOpsプロセスをサポートします。AKSは、開発者がアプリケーションのコードに集中できるようにするため、インフラストラクチャの管理を最小限に抑えます。"
  },
  {
    "serviceName": "Azure Container Instances",
    "description": "Azure Container Instancesは、コンテナベースのアプリケーションを簡単にデプロイ・実行するためのサービスです。仮想マシンの管理やクラスタのセットアップは不要で、コンテナの起動や停止が簡単に行えます。スケーラビリティや高可用性も提供され、コンテナのリソース使用量に応じて課金されます。また、Azure Container Registryと組み合わせることで、コンテナイメージのプライベートレジストリも利用できます。開発者や運用チームにとって、柔軟性と効率性を提供する優れたサービスです。"
  },
  // 省略
]

Azure Cognitive Search にベクトル化したデータを登録してみよう

Azure Cognitive Search にデータを登録します。Azure Cognitive Search はとりあえず使うだけなら Basic プランあたりを適当に作るだけで大丈夫です。ストレージサイズや可用性や検索速度とかが重要な場合は、価格レベルを上げたりレプリカを増やしたり(可用性上がる)、パーティションを増やしたり(早くなる)出来ます。

くわしくは、こちらのレベルの選択のドキュメントとサービスの制限を見て決めることになると思います。ここからはリンクを貼りませんが、その他にこのドキュメントと同じレベルにある容量を計画するなどのドキュメントも使用する際には目を通しておくといいでしょう。

ベクトル検索のコード例は、以下の GitHub のリポジトリで公開されているので、この記事で書くコードとあわせて、このリポジトリを参照すると良いと思います。

https://github.com/Azure/cognitive-search-vector-pr/tree/main/demo-dotnet

プロジェクトの作成とライブラリの追加

では、適当にコンソールアプリを作成して以下のライブラリを追加します。プレビュー版のライブラリを追加する場合は NuGet のパッケージマネージャーでプレビュー版を表示するようにしてから検索・追加してください。

  • Azure.AI.OpenAI (1.0.0-beta.6)
  • Azure.Search.Documents (11.5.0-beta.4)
  • Microsoft.Extensions.Configuration.Binder (最新の安定版)

続けてユーザーシークレットを追加して、Azure OpenAI Service と Azure Cognitive Search に接続するための情報を設定しておきます。

secrets.json
{
  "OpenAI": {
    "Endpoint": "https://リソース名.openai.azure.com/",
    "ApiKey": "OpenAI の API Key",
    "ModelName": "text-embedding-ada-002"
  },
  "CognitiveSearch": {
    "Endpoint": "https://リソース名.search.windows.net",
    "ApiKey": "Cognitive Search の管理者キー"
  }
}

注意点としては、このプログラムではインデックスの作成から検索までを単一のプログラムでやるので Azure Cognitive Search の管理者キーを使用していますが、一般的なアプリケーションではインデックスの作成と検索は別プログラムでやることが多いと思います。その場合はインデックスの作成などを行うようなアプリは管理者キーを使用して、クエリを行うようなアプリではクエリ キーを使用するようにしてください。

今回は、全部管理者キーでやります。

データをベクトル化して登録

では、データをベクトル化して登録していきたいと思います。
まずは Azure Cognitive Search でインデックスを作成します。インデックスの作成は SDK を使ってサクッと出来るのでやってしまいましょう。

Program.cs
using Azure;
using Azure.AI.OpenAI;
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
using Microsoft.Extensions.Configuration;

// モデルのベクトルの次元数
const int ModelDimensions = 1536;
// インデックス名
const string IndexName = "test-index";

// 設定の読み込み
var c = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();
var openAIOptions = c.GetSection(OpenAIOptions.KeyName).Get<OpenAIOptions>() ?? throw new InvalidOperationException();
var cognitiveSearchOptions = c.GetSection(CognitiveSearchOptions.KeyName).Get<CognitiveSearchOptions>() ?? throw new InvalidOperationException();

// AOAI と Cognitive Search への接続用クライアントの作成
var openAIClient = new OpenAIClient(
    new Uri(openAIOptions.Endpoint),
    new AzureKeyCredential(openAIOptions.ApiKey));
var cognitiveSearchClient = new SearchIndexClient(
    new Uri(cognitiveSearchOptions.Endpoint),
    new AzureKeyCredential(cognitiveSearchOptions.ApiKey));

// インデックスを作成する
await CreateIndexAsync(cognitiveSearchClient);

// インデックスを作成する
async ValueTask CreateIndexAsync(SearchIndexClient client)
{
    // ベクトル検索の設定名
    const string VectorSearchConfigName = "vector-config-for-test";

    var searchIndex = new SearchIndex(IndexName)
    {
        VectorSearch = new()
        {
            AlgorithmConfigurations =
            {
                new HnswVectorSearchAlgorithmConfiguration(VectorSearchConfigName)
            }
        },
        Fields =
        {
            // 一意識別のための列
            new SimpleField("id", SearchFieldDataType.String) { IsKey = true },
            // サービス名と概要
            new SimpleField("serviceName", SearchFieldDataType.String),
            // SearchableField が 1 つはないと検索できないので使わないけど SearchableField にしておく
            new SearchableField("description") { IsFilterable = true }, 
            // 概要のベクトルデータ
            new SearchField("descriptionVector", SearchFieldDataType.Collection(SearchFieldDataType.Single))
            {
                IsSearchable = true,
                VectorSearchDimensions = ModelDimensions,
                VectorSearchConfiguration = VectorSearchConfigName
            }
        }
    };

    // 作成または更新をする
    await client.CreateOrUpdateIndexAsync(searchIndex);
}

// 設定を読み込むためのクラス
class OpenAIOptions
{
    public const string KeyName = "OpenAI";
    public required string Endpoint { get; set; }
    public required string ApiKey { get; set; }
    public required string ModelName { get; set; }
}

class CognitiveSearchOptions
{
    public const string KeyName = "CognitiveSearch";

    public required string Endpoint { get; set; }
    public required string ApiKey { get; set; }
}

ポイントは CreateIndexAsync メソッドです。ここでインデックスを作成しています。descriptionVector という名前のフィールドが今回の肝となるベクトルデータを格納するためのフィールドです。
VectorSearch に設定している HnswVectorSearchAlgorithmConfiguration はベクトルの近似探索をするためのアルゴリズムで Hierarchical Navigable Small World というらしいです。多分、こちらの Navigable Small Worldによる近似最近傍探索 で言及されているものだと思います。SDK 内で定義されている AlgorithmConfiguration で終わる具象クラスは HnswVectorSearchAlgorithmConfiguration しかなさそうなので、今の所はこれを選択するしかできなさそうに見えます。今後増えるのか、REST API レベルではもうちょっと指定できる項目があるのかもしれませんが未調査です。また、コメントにも書いていますが SearchableField が 1 つ以上ないと検索が出来ない (ベクトルだけで検索する場合も SearchableField が必要) ので description フィールドは SearchableField にしています。

このプログラムを実行すると、以下のようにインデックスが作成されます。

続けて、ここにデータを登録します。データは先ほどプログラムで作成した services.json をプロジェクトに追加してコンテンツに指定して出力フォルダーにコピーされるように設定して使います。

そして Program.cs に以下のコードを追加します。services.json の中身を読み込んで Description をベクトル化して SearchDocument クラスのフィールドにデータを設定しています。SearchDocument を使って作成したインデックスの SearchClient を使ってデータのアップロード or マージを行っています。今回はサービス名をキーにしたかったのでサービス名から空白を取り除いたものを id としています。(空白は id に使えないので…。)

Program.cs
// ベクトル化
async ValueTask<float[]> GenerateEmbeddingsAsync(OpenAIClient openAIClient, string modelName, string text)
{
    var result = await openAIClient.GetEmbeddingsAsync(modelName, new EmbeddingsOptions(text));
    return result.Value.Data[0].Embedding.ToArray();
}

// データを作成したインデックスに追加する
async ValueTask InsertDataAsync(OpenAIClient openAIClient, string modelName, SearchIndexClient cognitiveSearchClient)
{
    static async ValueTask<ServiceDescription[]?> readAsync()
    {
        using var file = File.OpenRead("services.json");
        return await JsonSerializer.DeserializeAsync<ServiceDescription[]>(file, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        });
    }

    var serviceDescriptions = await readAsync() ?? throw new InvalidOperationException();
    List<SearchDocument> documents = new();
    foreach (var serviceDescription in serviceDescriptions)
    {
        var embeddings = await GenerateEmbeddingsAsync(openAIClient, modelName, serviceDescription.Description);
        documents.Add(new SearchDocument
        {
            ["id"] = serviceDescription.ServiceName.Replace(" ", ""),
            ["serviceName"] = serviceDescription.ServiceName,
            ["description"] = serviceDescription.Description,
            ["descriptionVector"] = embeddings,
        });
    }

    var searchClient = cognitiveSearchClient.GetSearchClient(IndexName);
    await searchClient.MergeOrUploadDocumentsAsync(documents);
}

class ServiceDescription
{
    public string ServiceName { get; set; } = "";
    public string Description { get; set; } = "";
}

メインの処理の最後に以下のコードを追加して実行するとデータが Cognitive Search の方に登録されます。

// データを登録する
await InsertDataAsync(openAIClient, openAIOptions.ModelName, cognitiveSearchClient);

プログラムを実行して Cognitive Search のインデックスのページを見ると以下の様にデータが追加されていることが確認できます。

ベクトル検索を試してみよう

長かったですが、やっと検索にたどり着きました。
ベクトル検索をするためには SearchClientSearchAsync メソッドの第二引数の SearchOptionsVectors プロパティに値を設定します。
Vectors には SearchQueryVector を指定して、ここにベクトル検索用のパラメーターを設定します。KNearestNeighborsCount がベクトル検索で返す件数です。恐らく普通の Cognitive Search の全文検索やセマンティック検索などとのハイブリッド検索をするときには、別のパラメーターの SizeKNearestNeighborsCount が異なるケースもあるのでしょうが、ベクトル検索しかしないのであれば SizeKNearestNeighborsCount に違う値を設定する理由は無いと思います。(誰か教えて。)
Fields は対象のフィールドで Value が検索キーワードをベクトル化したものを指定します。

ここでは、ユーザーにやりたいことを入力してもらって、その結果の上位 3 件を表示するようにしてみました。コードは以下のようになります。今回の記事の本題ではありませんが、SearchClientSearchIndexClient から作成する以外にも直接作成する方法もあるので、そちらで作っています。

Program.cs
// ベクトル検索
async ValueTask ReadWordAndSearchAsync(OpenAIClient openAIClient, string modelName, CognitiveSearchOptions options)
{
    // SearchIndexClient から SearchClient を作成する以外にも、直接 SearchClient を作る方法もあるのでそっちで作る
    // 普通は検索アプリは検索だけすることが多いと思うので、その場合はこのように SearchClient を作ることになる。
    var searchClient = new SearchClient(
        new Uri(options.Endpoint), IndexName, new AzureKeyCredential(options.ApiKey));

    while (true)
    {
        Console.Write("やりたいことを入力してください: ");
        var input = Console.ReadLine();
        if (string.IsNullOrEmpty(input) || input == "exit")
        {
            break;
        }

        var embeddings = await GenerateEmbeddingsAsync(openAIClient, modelName, input);

        var searchOptions = new SearchOptions
        {
            Vectors =
            {
                new SearchQueryVector
                {
                    KNearestNeighborsCount = 3, // 3 件返す
                    Fields = { "descriptionVector" },
                    Value = embeddings
                }
            },
            Size = 3, // 3 件返す
            Select = { "serviceName", "description" },
        };

        var response = await searchClient.SearchAsync<SearchDocument>(null, searchOptions);
        Console.WriteLine("やりたいことを実現できる可能性のあるサービスは以下のものになります。");
        await foreach (var result in response.Value.GetResultsAsync())
        {
            Console.WriteLine($"サービス名: {result.Document["serviceName"]}, スコア: {result.Score}");
            Console.WriteLine($"概要: {result.Document["description"]}");
            Console.WriteLine();
        }
    }
}

実行すると以下のような結果になりました。

やりたいことを入力してください: Webアプリをホスティングしたい。
やりたいことを実現できる可能性のあるサービスは以下のものになります。
サービス名: App Service, スコア: 0.8760748
概要: App Serviceは、クラウド上でWebアプリケーションやモバイルバックエンドを簡単に構築、デプロイ、スケールするためのサービスです。
自動的なスケーリングや高可用性、セキュリティ機能を提供し、複数のプログラミング言語やフレームワークに対応しています。
また、CI/CDパイプラインの統合やモニタリング、トラブルシューティングの機能も備えており、開発者にとって効率的な開発環境を提供します。

サービス名: Azure Functions, スコア: 0.85381174
概要: Azure Functionsは、イベント駆動型のサーバーレスコンピューティングプラットフォームです。コードを書いてデプロイする
だけで、自動的にスケーリングされる関数を作成できます。トリガーに応じて関数が実行され、処理が完了すると自動的に停止します。
さまざまなプログラミング言語で関数を作成でき、他のAzureサービスとの統合も容易です。短期間での開発やマイクロサービスの
実装に最適なサービスです。

サービス名: Azure Container Instances, スコア: 0.8443873
概要: Azure Container Instancesは、コンテナベースのアプリケーションを簡単にデプロイ・実行するためのサービスです。
仮想マシンの管理やクラスタのセットアップは不要で、コンテナの起動や停止が簡単に行えます。
スケーラビリティや高可用性も提供され、コンテナのリソース使用量に応じて課金されます。また、Azure Container Registryと組み合わせることで、
コンテナイメージのプライベートレジストリも利用できます。開発者や運用チームにとって、柔軟性と効率性を提供する優れたサービスです。

おっ、結構それっぽい!もう 1 ケース試してみました。

やりたいことを入力してください: データを保存したい。
やりたいことを実現できる可能性のあるサービスは以下のものになります。
サービス名: SQL Database, スコア: 0.8276395
概要: Azure SQL Databaseは、クラウドベースのリレーショナルデータベースサービスです。
高い可用性、スケーラビリティ、セキュリティを提供し、アプリケーションのパフォーマンスを向上させます。
データのバックアップ、復元、監視も簡単に行えます。また、柔軟な料金体系と自動チューニング機能も備えています。開発者や企業にとって、簡単かつ
効率的なデータベース管理のための理想的な選択肢です。

サービス名: Azure Key Vault, スコア: 0.8215678
概要: Azure Key Vaultは、クラウドネイティブなセキュリティソリューションであり、機密情報の保管と管理を提供します。
APIキー、パスワード、証明書などの機密情報を安全に保存し、アプリケーションからのアクセスを制御できます。
また、キーの自動ローテーションやアクセス監査などの高度な機能も備えています。Azure Key Vaultは、セキュリティとコンプライアンスを重視する
組織にと って重要なツールです。

サービス名: CosmosDB, スコア: 0.81921506
概要: Cosmos DBは、Microsoft Azureのグローバルに分散されたマルチモデルデータベースサービスです。
スケーラビリティ、可用性、パフォーマンスに優れ、JSONドキュメント、キーバリュー、列指向、グラフデータモデルをサポートしています。
また、多くのAPI (SQL、MongoDB、Cassandra、Gremlinなど)を提供し、開発者が最適な方法でデータを操作できます。
Cosmos DBは、グローバルなス ケールでのアプリケーションの要件を満たすために設計されています。

これもそれっぽいですね。いい感じ。

ソースコード

今回の記事を書くにあたって書いたコードは GitHub にアップしています。

https://github.com/runceel/CognitiveSearchLab

まとめ

ということで、Azure Cognitive Search でベクトル検索を試してみました。思ったよりすんなり動いたので一般提供開始が待ち遠しいですね。
個人的にはベクトル検索したいだけなのに SearchableField が 1 つ必須なところ (間違ってたらごめんなさい) が気になりました。まぁ、普通はベクトル検索と他の検索を組み合わせてやるんですかね?

テストデータを Azure OpenAI Service を使ってやったのですが、これが結構簡単にそれっぽいデータが出来たのでこの方法も気に入りました。そのうち Azure Cognitive Service のインデクサーでベクトル化してデータ登録してくれるようなものが出てくるのかもしれないですが、今の所そういうものはなさそうなので、こんな感じでプログラムや REST API や CLI などでインデックスを作ってデータの登録をやっていくことになるんだと思います。

前回の記事の C# で PDF をテキスト化したい (Azure AI Document Intelligence (旧 Form Recognizer)) と今回の内容を組み合わせることで PDF からテキストを抜いてベクトル化して Azure Cognitive Search に登録してベクトル検索するということが出来るようになります。割とよくあるパターンだと思うので、元気があったらこれを組み合わせたパターンのものを作ってみようと思います。

Python が嫌いなわけではないけど個人的には C# が一番好きなので最近多い流行りのパターンの C# に関する情報はあった方がいいと思うので頑張ろ。

Microsoft (有志)

Discussion