KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

【C#】Azure Cosmos DB for NoSQL でチャットボットを想定したベクトル検索を実装してみた

第2開発部でバックエンドを担当している N.Y です。

先月、「Azure Cosmos DB のベクトル検索を用いたチャットボット」を作成する機会がありました。
ベクトル検索って、数学が苦手な人には少々ハードルが高く聞こえませんか? まさに私がそうでした。

実際のところ、ベクトル検索で内部的に使用されている技術はがっつりベクトルが使用されているのですが、 Cosmos DB ではそのあたりをあまり意識せずにベクトル検索を実装することができます。

今回は、その Cosmos DB を用いたベクトル検索 について、チャットボットで使用されることを想定した実装方法をアウトプットしようと思います。

メインで使用するサービスは Cosmos DB , Azure OpenAI で、使用言語は C# です。

※チャットボットの画面作成および画面上で実際に呼ばれる、といったところについては割愛しています。

サーバー構成図

ベクトル検索を用いたチャットボットのサーバー構成図です。

各サービスの主な役割は以下になります。

  • Azure OpenAI

    データのベクトル化で使用する埋め込みモデル(単語・文章・画像・アイテムなどを数値のベクトルに変換する AI モデル)をデプロイ

  • Azure Cosmos DB

    ベクトル化したデータの保存・ベクトル検索

  • App Service

    データの保存や、検索を行うAPI
    ※今回は Swagger からデータ保存・検索を行うため作成しません。

サーバー構成図からも分かるように、チャットボットの作成は大きく2つのステップに分かれています。

ステップ1. データの登録

ステップ1では、管理者が検索対象のデータを Cosmos DB に登録します。
データは、Azure OpenAI の埋め込みモデルを用いてベクトル化したものを保存しておきます。

ステップ2. チャットボットへの質問

ステップ2では、質問を受け付け結果を返却します。
受け付けた質問内容を、 Azure OpenAI の埋め込みモデルを用いてベクトル化します。
ステップ1でベクトル化した検索対象のデータと、ステップ2でベクトル化した質問内容を突合することでベクトル検索が可能になります。

必要な Azure サービスを準備しよう

まずは、必要なサービスの下準備から始めましょう。

Azure OpenAI の作成

以下を参考に、Azure OpenAI を作成してください。 learn.microsoft.com

  • ポイント

リージョンは、多くのモデルが使用できるため East US あたりがオススメです。

埋め込みモデルをデプロイ

できあがった Azure OpenAI のページから、以下のボタンで Azure AI Foundry に移動します。

デプロイから、基本モデルのデプロイを選択します。

text-embedding-3-small を検索して、デプロイしましょう。

これで、 Azure OpenAI の準備は完了です!

Cosmos DB のアカウントを作成

次に、 Cosmos DB を作成していきましょう。
以下のクイックスタートを参考に、Cosmos DB のアカウントを作成してください。 learn.microsoft.com

ベクトル検索の設定を有効化

設定 > 機能 からVector Search for NoSQL API の設定を有効化します。

コンテナー作成

Cosmos DB のコンテナーを作成します。
このタイミングで、コンテナーに投入するデータ構造をある程度決定しておく必要があります。 今回は以下のような日記データを入れる想定で進めていきます。

  • コンテナー名:Diary
{
    "id": "d72c3f08-6fba-4b54-b25e-776f61cc95d1", // Guid
    "title": "3/1の日記 ドライブ日和",
    "content": "今日は御殿場までドライブをしました。天気も良く、富士山も綺麗に見えました。",
    "vectorContent": "" // ベクトル化した日記の本文を入れる予定
}

Cosmos DB の データエクスプローラー から、 + New Container をクリックし Database と Container を一気に作成します。
作成時に、ベクトル化した日記の本文を入れる予定の vectorContent の項目を Container Vector Policy の設定に入れておきます。

Container Vector Policy に設定した項目には、ベクトルの効率的な類似性検索を行うための情報やインデックスが作成されます。
Container Vector Policy の設定値の詳細は以下をご覧ください。

learn.microsoft.com

  • ポイント

Container Vector Policy はコンテナーの作成時しか設定できず、後から設定・編集することは現時点(2025/3/11)ではできません!ご注意ください。

ここまでで、Azure リソースの準備は完了です。

Cosmos DB でベクトル検索

続いて、C# のコードで Cosmos DB にサンプルデータを投入する API と、Cosmos DB からデータを検索する API を作成していきます。
今回は以下のような環境でコードを書いています。

  • 環境

VisualStudio2022

  • プロジェクトテンプレート

ASP.NET Core WebAPI

Nuget パッケージのインストール

以下の Nuget パッケージが必要となるため、インストールします。

  • Microsoft.Azure.Cosmos

  • Azure.AI.OpenAI

  • Newtonsoft.Json

接続文字列を設定

AppSettings.cs という名前で、接続文字列がバインドされるクラスを作成しておきます。

public class AppSettings
{
    public string CosmosDBConnection { get; set; } = string.Empty;
    public string AoaiEndpoint { get; set; } = string.Empty;
    public string AoaiApiKey { get; set; } = string.Empty;
}

appsettings.json のファイルに、接続文字列の値を入力するための空の項目を追加します。
このとき、キーは先ほど追加した AppSettings.cs クラスの項目名と一言一句同じになるようにしておいてください。

{
  ~省略~
  "CosmosDBConnection": "",
  "AoaiEndpoint": "",
  "AoaiApiKey": ""
}

Azure ポータルで、Cosmos DB の接続文字列をコピーします。
Cosmos DB のサービス上で、キー のタブを選択すると、Cosmos DB の接続文字列を確認することができます。
PRIMARY CONNECTION STRING の値をコピーします。

コピーした接続文字列を appsettings.json の CosmosDBConnection のところに貼り付けます。

次に、Azure OpenAI の接続文字列を取得します。
Azure ポータルの Azure OpenAI 画面から、再度 Azure AI Foundry を開きます。
右上に、自分が作成した Azure OpenAI のサービス名が表示されていると思います。 そこをクリックすると、リソース詳細とともに エンドポイントキー1 が表示されるので、両方をコピーします。

コピーした エンドポイント を appsettings.json の AoaiEndpoint に、キー1 を AoaiApiKey に貼り付けます。

これで接続文字列の準備は完了です。

Program.cs で接続文字列を DI する

Program.cs で、Cosmos DB のコンテナー接続用クライアント、およびベクトル化に使用する Azure OpenAI の埋め込みモデル(text-embedding-3-small)用クライアントをシングルトンで DI します。

using Azure;
using Azure.AI.OpenAI;
using Microsoft.Azure.Cosmos;
using TechBSampleAPI;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
var configuration = builder.Configuration;
var appSettings = configuration.Get<AppSettings>();

if (appSettings != null)
{
    // CosmosDB 接続用設定
    var cosmosClient = new CosmosClient(appSettings.CosmosDBConnection, new CosmosClientOptions() { Serializer = new CustomSerializer() });
    builder.Services.AddSingleton(cosmosClient.GetContainer("SampleDB", "Diary"));

    // 埋め込みモデル接続用設定
    var aoaiClient = new AzureOpenAIClient(new Uri(appSettings.AoaiEndpoint), new AzureKeyCredential(appSettings.AoaiApiKey));
    builder.Services.AddSingleton(aoaiClient.GetEmbeddingClient("text-embedding-3-small"));
}

~省略~

上記のコードで既に設定されていますが、Cosmos DB とのデータバインドを円滑に行うために
以下のようなカスタムシリアライザーを作成しておくと便利です。
キャメルケースとパスカルケースを良い感じに変換するシリアライザーとなっております。

public class CustomSerializer : CosmosSerializer
{
    private static readonly JsonSerializerOptions writeOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    private static readonly JsonSerializerOptions readOptions = new()
    {
        PropertyNameCaseInsensitive = true
    };

    public override T FromStream<T>(Stream stream)
    {
        using var reader = new StreamReader(stream);
        return JsonSerializer.Deserialize<T>(reader.ReadToEnd(), readOptions) ?? throw new Exception();
    }

    public override Stream ToStream<T>(T input)
        => new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input, writeOptions)));
}

これで接続の基盤部分は作成できました。

Post API でベクトル化したデータを投入

次に、Program.cs で DI した ContainerEmbeddingClient をコンストラクタで受け取り、Cosmos DB にベクトル化したデータを保存する Post API を作成します。

[ApiController]
[Route("[controller]")]
public class SampleController : ControllerBase
{
    private readonly Container _container;
    private readonly EmbeddingClient _embeddingClient;

    public SampleController(Container cosmosContainer, EmbeddingClient embeddingClient)
    {
        _container = cosmosContainer;
        _embeddingClient = embeddingClient;
    }

    [HttpPost]
    public async Task<DiaryEntity> Post([FromBody] DiaryModel model)
    {
        var diaryEntity = new DiaryEntity
        {
            Id = Guid.NewGuid().ToString(),
            Title = model.Title,
            Content = model.Content
        };

        // Content をベクトル化 (埋め込みモデルを利用)
        var res = await _embeddingClient.GenerateEmbeddingAsync(model.Content);
        diaryEntity.VectorContent = res.Value.ToFloats().ToArray();

        // Cosmos DB に保存
        return await _container.CreateItemAsync(diaryEntity, new PartitionKey(diaryEntity.Id));
    }
}

public class DiaryModel
{
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
}

public class DiaryEntity
{
    public string Id { get; set; } = string.Empty;
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public float[] VectorContent { get; set; } = [];
}

ベクトル化されたデータは、 float[] 型となっている点に注意してください。

デバッグすると Swagger が開くと思うので、Swagger からサンプルデータを投入してみましょう。

データが投入されていることが確認できました!

後ほどベクトル検索したいので、他にもいくつかデータを投入しておきます。

Get API でベクトル検索を実施する

次に、検索用の Get API を作成します。
Post API を作成したコントローラーに、以下のように記述します。

    [HttpGet]
    public async Task<ScoreDiaryModel[]> Get(string keyword)
    {
        // 検索キーワードをベクトル化 (埋め込みモデルを利用)
        var keywordRes = await _embeddingClient.GenerateEmbeddingAsync(keyword);
        var vectorKeyword = keywordRes.Value.ToFloats().ToArray();

        // ベクトル検索のクエリを作成
        var query = new QueryDefinition(
            "SELECT TOP @k c.id, c.title, c.content, " +
            "VectorDistance(c.vectorContent, @vector) AS score FROM c " +
            "WHERE VectorDistance(c.vectorContent, @vector) > @score " +
            "ORDER BY VectorDistance(c.vectorContent, @vector)")
            .WithParameter("@vector", vectorKeyword)
            .WithParameter("@k", 3)
            .WithParameter("@score", 0.4);

        // ベクトル検索を実行
        using FeedIterator<ScoreDiaryModel> feed = _container.GetItemQueryIterator<ScoreDiaryModel>(query);
        List<ScoreDiaryModel> result = [];
        while (feed.HasMoreResults)
        {
            result.AddRange([.. await feed.ReadNextAsync()]);
        }

        return result.ToArray();
    }

public class ScoreDiaryModel : DiaryModel
{
    public float Score { get; set; }
}

結果とともにスコアも確認したかったため、スコア返却用のモデルを作成しました。

  • ベクトル検索のクエリについて

Cosmos DB でベクトル検索を行いたい場合は、 VectorDistance という Azure で用意されているベクトル関数を使用することで、簡単に 2 つのベクトル間の類似度を計算することができます。
今回作成したクエリでは

WHERE VectorDistance(c.vectorContent, @vector) > @score 

の部分で、 「Cosmos DB の vectorContent 項目」と「ベクトル化した質問内容」の類似度が計算されることによって、質問内容の意味に近いデータを結果として得ることができるようになっています。

また、スコアが 0.4 以上の結果だけを取得することで、類似度が低い回答を省くようにしています。

こちらも Swagger で動作を見てみましょう。
天気が晴れの日の日記を検索してみます。

天気が雨のデータもある中、晴れの日のデータだけが返却されました。
これでベクトル検索ができていることが確認できましたね!

長丁場になってしまいました。お疲れ様でした!
ここまで読んでいただき、ありがとうございます。

まとめ

ベクトル検索を実装する、というのは一見ハードルが高そうに思えますが
Cosmos DB で用意されているクエリや設定を使用することで、比較的シンプルに実装が出来たように思います。

実際のチャットボットでは、もう少し人間らしい回答がされるように要約をかけたりすることが望ましいです。
また、検索の精度が悪いことも往々にしてあると思いますので、投入データを工夫したり使用するモデルを変更したり・・・ まだまだ奥が深そうです。

今後も、AI を使用した Azure サービスがどんどん出てくると思いますので、実際に動かして技術をキャッチアップしていきたいです!!

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。

recruit.kentem.jp

career.kentem.jp