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