😽

.NET + Semantic Kernel でベクトル検索の RAG をする

に公開

はじめに

今日 Daichi Isami さんに Semantic Kernel のベクトル検索系の質問を受けて、そういえば、その機能はまだ安定してないだろうし触らないでおこうと思っていたので、よくわからないということがありました。そろそろ Microsoft.Extensions.VectorData.Abstractions も安定版が出たので、一度触るいい機会だと思うので軽く触ってみたメモです。

この記事を書こうと思った Daichi Isami さんの記事は以下になります。

C# Semantic Kernel で簡単 RAG のサンプル ~VolatileMemoryStore編~

こっちは Semantic Kernel の初期からあるメモリー系の機能を使ったものになります。
私は Microsoft.Extensions.VectorData を使ったものになります。個人的な感覚では、こっちの方が正式リリースまでいくのではないかと睨んでいるのですが、実際にはどうなるかわかりません。あくまで個人的な感覚です。

Semantic Kernel のベクトルストア系パッケージ

ベクトルストア系のパッケージは Microsoft.Extensions.VectorData.Abstractions というパッケージが抽象化レイヤーで、それを実装する形でいろいろなベクトルストアに対応するといった形になっています。長らくプレビュー版だったのですが Build 2025 にあわせて v9.5.0 という安定板が出ていました。ということで安心して触っていけそうです。

ただし、これを実装した Microsoft.SemanticKernel.Connectors.AzureAISearch などのパッケージは、残念ながらまだプレビューになります。もう一声って感じですね。

VectorStore クラス

まずは、Microsoft.Extensions.VectorData.Abstractions パッケージで定義されている Microsoft.Extensions.VectorData.VectorStore クラスを見てみます。これは名前のとおりベクトルストアを抽象化したクラスで GetCollectionListCollectionNamesAsync などのベクトルストアのコレクションを扱うためのメソッドが定義されています。

このクラスを実装しているクラスには以下のようなクラスがあります。

  • AzureAISearchVectorStore
  • CosmosMongoVectorStore
  • CosmosNoSqlVectorStore
  • InMemoryVectorStore
  • MongoVectorStore
  • PineconeVectorStore
  • PostgresVectorStore
  • QdrantVectorStore
  • RedisVectorStore
  • SqlServerVectorStore
  • SqliteVectorStore
  • WeaviateVectorStore

名前からして代表的なベクトルストアに対する実装が用意されています。今回は、特別なベクトル DB には依存しない感じにしたいので InMemoryVectorStore を掘り下げて基本的な VectorStore の使い方を見ていきたいと思います。

InMemoryVectorStore クラス

InMemoryVectorStore クラスは、メモリ上にベクトルストアを構築するためのクラスです。これを使うことで、特別なベクトル DB を用意しなくても Semantic Kernel のベクトルストア機能を試すことができます。早速使ってみましょう。コンソールアプリを作成して以下のパッケージを追加します。

  • Microsoft.SemanticKernel.Connectors.InMemory v1.52.1-preview

このパッケージから Microsoft.Extensions.VectorData.Abstractions パッケージが依存関係として追加されるので、他には特に追加する必要はありません。
では C# のコードを書いて試していきます。まずは、ベクトルストアに登録するクラスを定義します。このクラスには VectorStoreKey 属性と VectorStoreData 属性、VectorStoreVector 属性を付与します。VectorStoreVector 属性は、ベクトルの次元数を指定します。例えば IdTextVector (ここにベクトルデータを入れる想定) のプロパティを持つクラスを定義する場合は以下のようになります。

using Microsoft.Extensions.VectorData;

class RecordData
{
    [VectorStoreKey]
    public required string Id { get; set; }
    [VectorStoreData]
    public required string Text { get; set; }
    [VectorStoreVector(3)]
    public required ReadOnlyMemory<float> Vector { get; set; }
}

今回は本格的にベクトル化するつもりはないのでテストデータを入れやすいようにベクトルは 3 次元の float 配列を使います。次に、InMemoryVectorStore を使ってベクトルストアを作成します。以下のように InMemoryVectorStore をインスタンス化して、データを格納するためのコレクションを取得して、適当にデータを投入して Id で検索してみましょう。

using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;

// InMemoryVectorStore を作成
VectorStore vectorStore = new InMemoryVectorStore();
// コレクションを作成
var collection = vectorStore.GetCollection<string, RecordData>("TestCollection");
await collection.EnsureCollectionExistsAsync();

// データを登録
await collection.UpsertAsync([
        new() { Id = "1", Text = "Hello world", Vector = new float[] { 0.1f, 0.2f, 0.3f } },
        new() { Id = "2", Text = "Goodbye world", Vector = new float[] { 0.4f, 0.5f, 0.6f } },
        new() { Id = "3", Text = "Hello again", Vector = new float[] { 0.7f, 0.8f, 0.9f } },
    ]);

// データを取得して表示
var data = await collection.GetAsync("1", new() { IncludeVectors = true });
if (data == null)
{
    Console.WriteLine("Data not found.");
    return;
}

// データを表示
Console.WriteLine($"Id: {data.Id}, Text: {data.Text}");

実行すると以下のように表示されます。

Id: 1, Text: Hello world

ちゃんと目的の Id1 のデータが取れています。次にベクトル検索をしてみます。ベクトル検索は SearchAsync メソッドを使います。ベクトルデータとトップ何件を取得するかといったパラメーターを指定してあげるといい感じに検索してくれます。SearchAsync の戻り値は IAsyncEnumerable<VectorSearchResult<TRecord>> 型になります。await foreach で回すと、検索結果を非同期で取得することができます。VectorSearchResult<TRecord> は、検索結果のスコアとレコードデータを持っています。TRecord は、今回のケースでは RecordData クラスを指定します。

では、先ほどのコードに続けて以下のコードを追加してベクトル検索をしてみます。

// ベクトル検索
Console.WriteLine("-- ベクトル検索 --");
ReadOnlyMemory<float> vector = new([0.1f, 0.2f, 0.3f]);
await foreach (var result in collection.SearchAsync(vector, 2))
{
    var score = result.Score;
    var recordData = result.Record;
    Console.WriteLine($"Id: {recordData.Id}, Text: {recordData.Text} (Score: {score})");
}

Id1 のベクトルと同じベクトルデータで検索をして、上位2件を取得するようにしています。実行すると以下のように表示されます。

Id: 1, Text: Hello world
-- ベクトル検索 --
Id: 1, Text: Hello world (Score: 1)
Id: 2, Text: Goodbye world (Score: 0.9746319055557251)

先ほどの結果に続いてベクトル検索の結果が出てきました。ちゃんと Id1 のデータが最初に来て、次に Id2 のデータが出てきています。

この SearchAsync メソッドは、上位何件のデータを取得するかのパラメーター以外に VectorSearchOptions<TRecord> 型のオブジェクトを渡すことが出来ます。これにはデータを読み飛ばす件数を指定する Skip や、検索結果にベクトルデータを含めるかどうかの IncludeVectors プロパティなどがあります。これらのプロパティを使うことで、より細かい検索が可能になります。例えばページングをする場合はベクトル検索のコードを以下のように変えることで実現できます。

// ページング対応
var pageIndex = 0;
bool hasMoreResults = true;
const int pageSize = 2;
while (hasMoreResults)
{
    // データの続きがある限り Skip の値を更新しつつ検索を続ける
    Console.WriteLine($"--- page: {pageIndex + 1} ---");
    var dataCount = 0;
    await foreach (var result in collection.SearchAsync(vector, 2, new() { Skip = pageIndex * pageSize }))
    {
        var score = result.Score;
        var recordData = result.Record;
        Console.WriteLine($"Id: {recordData.Id}, Text: {recordData.Text} (Score: {score})");
        dataCount++;
    }

    hasMoreResults = dataCount == pageSize;
    pageIndex++;
}

実行すると以下のように表示されます。

-- ベクトル検索 --
--- page: 1 ---
Id: 1, Text: Hello world (Score: 1)
Id: 2, Text: Goodbye world (Score: 0.9746319055557251)
--- page: 2 ---
Id: 3, Text: Hello again (Score: 0.9594119787216187)

pageSize2 にしているので、1ページ目に Id12 のデータが表示されて、2ページ目に Id3 のデータが表示されています。

Semantic Kernel とともに使う

では、これを Semantic Kernel と共に使ってみます。
プロジェクトに以下のパッケージを追加します。

  • Microsoft.SemanticKernel v1.52.1: Semantic Kernel の本体
  • Azure.Identity v1.14.0: Managed Identity を使うために必要

Semantic Kernel と共に VectorStore を使う場合は KernelServicesAddInMemoryVectorStore メソッドを使って InMemoryVectorStore を追加出来ます。DI コンテナから VectorStore を直接取得して使ってもいいですし、別のクラスにコンストラクタインジェクションして使ってもいい感じです。今回は愚直に GetRequiredServiceVectorStore を取得して使う形にします。以下のように KernelBuilder を使って InMemoryVectorStore を追加して、Kernel を作成します。

ついでに私の Azure サブスクリプション上にデプロイしている Azure OpenAI サービスにデプロイしている text-embedding-3-large を使って、実際にベクトル化もしてみます。

#pragma warning disable SKEXP0010
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel;

// KernelBuilder を作成
var builder = Kernel.CreateBuilder();
// EmbeddingGenerator サービスを追加 (Preview 機能)
builder.AddAzureOpenAIEmbeddingGenerator("text-embedding-3-large",
    "https://<<AOAI リソース名>>.openai.azure.com/",
    new AzureCliCredential());

// InMemoryVectorStore を追加
builder.Services.AddInMemoryVectorStore();

// Kernel を作成
var kernel = builder.Build();

// Kernel から VectorStore を取得
VectorStore vectorStore = kernel.Services.GetRequiredService<VectorStore>();
// コレクションを作成
var collection = vectorStore.GetCollection<string, RecordData>("TestCollection");
await collection.EnsureCollectionExistsAsync();

// ベクトル化のサービスを追加
var embeddingGenerator = kernel.Services.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>();

// データを登録
await collection.UpsertAsync([
        new() { Id = "1", Text = "Hello world", Vector = (await embeddingGenerator.GenerateAsync("Hello world")).Vector },
        new() { Id = "2", Text = "Goodbye world", Vector = (await embeddingGenerator.GenerateAsync("Goodbye world")).Vector  },
        new() { Id = "3", Text = "Hello again", Vector = (await embeddingGenerator.GenerateAsync("Hello again")).Vector },
    ]);

// ベクトル検索
Console.WriteLine("-- ベクトル検索 --");
var vector = (await embeddingGenerator.GenerateAsync("また会おう!!")).Vector;

// ページング対応
var pageIndex = 0;
bool hasMoreResults = true;
const int pageSize = 2;
while (hasMoreResults)
{
    // データの続きがある限り Skip の値を更新しつつ検索を続ける
    Console.WriteLine($"--- page: {pageIndex + 1} ---");
    var dataCount = 0;
    await foreach (var result in collection.SearchAsync(vector, 2, new() { Skip = pageIndex * pageSize }))
    {
        var score = result.Score;
        var recordData = result.Record;
        Console.WriteLine($"Id: {recordData.Id}, Text: {recordData.Text} (Score: {score})");
        dataCount++;
    }

    hasMoreResults = dataCount == pageSize;
    pageIndex++;
}

登録データは先ほどのものと同じで、検索のためのデータは また会おう!! というテキストをベクトル化して検索しています。実行すると以下のように表示されます。

-- ベクトル検索 --
--- page: 1 ---
Id: 3, Text: Hello again (Score: 0.43020400404930115)
Id: 2, Text: Goodbye world (Score: 0.3199577033519745)
--- page: 2 ---
Id: 1, Text: Hello world (Score: 0.22264428436756134)

ちゃんと、また会おう!! に近そうな Hello again が最初に来て、次に Goodbye world が来て最後に Hello world が来ています。なんとなく雰囲気的に近い順に並んでいそうですね。

テキスト検索に特化してみよう

VectorStore をラップしてテキスト検索に特化する機能も提供されています。これは VectorStoreTextSearch<TRecord> クラスを使います。VectorStoreTextSearch<TRecord> は、ITextSearch インターフェースを実装していて、テキスト検索を行うことができます。VectorStoreTextSearch<TRecord> を使うことで、ベクトルストアのデータをテキスト検索に特化した形で扱うことができます。このクラスでテキスト検索を行うようにするためには RecordData クラスの検索結果のテキストに TextSearchResultValue 属性を付与する必要があります。これを付与することで、テキスト検索の結果を取得する際に、どのプロパティを使うかを指定することができます。例えば、RecordData クラスの Text プロパティに TextSearchResultValue 属性を付与する場合は以下のようになります。

class RecordData
{
    [VectorStoreKey]
    public required string Id { get; set; }
    [TextSearchResultValue]
    [VectorStoreData]
    public required string Text { get; set; }
    [VectorStoreVector(3)]
    public required ReadOnlyMemory<float> Vector { get; set; }
}

今回は使用しませんが、そのほかに TextSearchResultName 属性を指定することで名前として使うプロパティや、TextSearchResultLink 属性を指定することでリンクとして使うプロパティを指定することができます。これらの属性を使うことで例えば「ファイル名」「チャンク化されたテキスト」「ファイルへのリンクのURL」などを指定することができます。今回はテキストだけを使うので TextSearchResultValue 属性だけを付与します。

では実際に VectorStore から VectorStoreTextSearch<TRecord> を使ってテキスト検索をしてみます。

// Kernel から VectorStore を取得
VectorStore vectorStore = kernel.Services.GetRequiredService<VectorStore>();
// コレクションを作成
var collection = vectorStore.GetCollection<string, RecordData>("TestCollection");
await collection.EnsureCollectionExistsAsync();

// ベクトル化のサービスを追加
var embeddingGenerator = kernel.Services.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>();

// データを登録
await collection.UpsertAsync([
        new() { Id = "1", Text = "Hello world", Vector = (await embeddingGenerator.GenerateAsync("Hello world")).Vector },
        new() { Id = "2", Text = "Goodbye world", Vector = (await embeddingGenerator.GenerateAsync("Goodbye world")).Vector  },
        new() { Id = "3", Text = "Hello again", Vector = (await embeddingGenerator.GenerateAsync("Hello again")).Vector },
    ]);

// テキスト検索用のクラスで VectorStore のコレクションをラップ
var textSearch = new VectorStoreTextSearch<RecordData>(
    collection,
    embeddingGenerator);

var result = await textSearch.GetTextSearchResultsAsync("さよなら…、世界。");
await foreach (var textSearchResult in result.Results)
{
    var item = textSearchResult.Value;
    Console.WriteLine(item);
}

これを実行すると以下のような結果になります。ちゃんと さよなら…、世界。 に一番近そうな Goodbye world が先頭に出てきています。

Goodbye world
Hello world
Hello again

RAG 的に使ってみる

ここまでのコードで適当にデータを投入してテキスト検索ができるようになりました。これを使って RAG 的に使ってみようと思います。とりあえずは、先ほど VectorStoreTextSearch<TRecord> を自分でインスタンス化していましたが DI コンテナにお任せしたいと思います。色々気を付けるところはありますが、テキスト検索を出来るようにサービスを登録する場合のポイントは以下になります。

  • VectorStoreTextSearch<TRecord>ITextSearch インターフェースで DI コンテナに登録
  • VectorStoreTextSearch<TRecord> はコンストラクタに IVectorSearchable<TRecord> を受け取るので VectorStore から取得したコレクションは、この型で DI コンテナに登録する
  • IEmbeddingGenerator を DI コンテナに登録しておく
  • ITextSearch の検索結果の文字列表現として使いたいプロパティに対して TextSearchResultValue 属性を付与する
    • 今回の場合は RecordData クラスの Text プロパティに付与する
  • ITextSearchCreateWithSearch メソッドでプラグイン化できるので、それを DI コンテナに登録しておく

それでは、やってみましょう。

#pragma warning disable SKEXP0010
#pragma warning disable SKEXP0001
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Data;

// KernelBuilder を作成
var builder = Kernel.CreateBuilder();
// ChatCompletion サービスを追加
builder.AddAzureOpenAIChatCompletion("gpt-4.1",
    "https://<<AOAIのリソース名>>.openai.azure.com/",
    new AzureCliCredential());
// EmbeddingGenerator サービスを追加 (Preview 機能)
builder.AddAzureOpenAIEmbeddingGenerator("text-embedding-3-large",
    "https://<<AOAIのリソース名>>.openai.azure.com/",
    new AzureCliCredential());

// InMemoryVectorStore を追加してテキスト検索するためのサービスをセットアップ
builder.Services.AddInMemoryVectorStore();
builder.Services.AddSingleton<IVectorSearchable<RecordData>>(sp => 
    sp.GetRequiredService<VectorStore>().GetCollection<string, RecordData>("TestCollection"));
builder.Services.AddSingleton<ITextSearch, VectorStoreTextSearch<RecordData>>();

// ITextSearch をプラグインとして登録
builder.Services.AddSingleton(sp => sp.GetRequiredService<ITextSearch>().CreateWithSearch("GreetingStore", "色々な挨拶のデータストア"));

// Kernel を作成
var kernel = builder.Build();

// Kernel から VectorStore を取得
VectorStore vectorStore = kernel.Services.GetRequiredService<VectorStore>();
// コレクションを作成
var collection = vectorStore.GetCollection<string, RecordData>("TestCollection");
await collection.EnsureCollectionExistsAsync();

// ベクトル化のサービスを追加
var embeddingGenerator = kernel.Services.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>();

// データを登録
await collection.UpsertAsync([
        new() { Id = "1", Text = "Hello world", Vector = (await embeddingGenerator.GenerateAsync("Hello world")).Vector },
        new() { Id = "2", Text = "Goodbye world", Vector = (await embeddingGenerator.GenerateAsync("Goodbye world")).Vector  },
        new() { Id = "3", Text = "Hello again", Vector = (await embeddingGenerator.GenerateAsync("Hello again")).Vector },
    ]);

// RAG の本番!
var r = await kernel.InvokePromptAsync(
    // 挨拶を返すプロンプト
    """
    <message role="system">
        あなたは挨拶のプロフェッショナルです。
        ユーザーが日本語で入力した挨拶に対して、一番最適な挨拶を英語で返してください。
        その際に一般的な知識は使用せずに、あなたのデータストアに登録されている情報のみを使用してください。
    </message>
    <message role="user">
        {{$input}}
    </message>
    """,
    new(new PromptExecutionSettings
        {
            // データストアを使用するための設定 (自動でプラグインを呼び出す)
            FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
        })
    {
        // プロンプトに埋め込む変数
        ["input"] = "さらばだ!",
    });

Console.WriteLine(r.GetValue<string>());

class RecordData
{
    [VectorStoreKey]
    public required string Id { get; set; }
    [TextSearchResultValue]
    [VectorStoreData]
    public required string Text { get; set; }
    [VectorStoreVector(3)]
    public required ReadOnlyMemory<float> Vector { get; set; }
}

AI に「さらばだ!!」に一番あう英語の挨拶を返してもらうようにしています。私が期待しているのは VectorStore から Goodbye world を取得して、それを回答してくれることです。

それでは実行してみましょう!以下のような結果になりました。

Goodbye world.

ちゃんと Goodbye world という結果が返ってきました。RAG 出来ていそうですね。

まとめ

ということで、Semantic Kernel のベクトルストア機能を使って RAG を実現してみました。InMemoryVectorStore を使うことで、特別なベクトル DB を用意しなくても Semantic Kernel のベクトルストア機能を試すことができました。さらに、VectorStoreTextSearch<TRecord> を使うことで、テキスト検索に特化した形でベクトルストアを扱うことができました。最後に、RAG 的に使うための DI コンテナのセットアップも紹介しました。
Semantic Kernel のベクトルストア機能は、まだプレビュー版の機能が多いですが、今後のアップデートに期待したいと思います。特に、Azure AI Search などのベクトル DB に対応したパッケージが安定版になることを期待しています。

Microsoft (有志)

Discussion