😎

Semantic Kernel で Open AI の Embeddings を使う (あいまい検索出来てすげーやつ)

2023/05/05に公開

はじめに

以前、Azure OpenAI Service の SDK を使って試してみた Emgeddings (埋め込み) を Semantic Kernel で使ってみます。

使ってみよう

Azure OpenAI Service 側で text-embedding-ada-002 を作っておきましょう。今の所 001 の davinci よりも 002 の ada の方がスコアがいいらしいです。Embeddings を使うには Semantic Kernel のカーネルの作成時に Embeddings の設定と WithMemoryStorage で保存先を設定したうえでカーネルを作成します。

具体的には以下のようになります。CreateConsoleLogger メソッドは後でコードを出そうと思いますが ILogger を作ってるだけのメソッドなので今回の本質ではありません。ポイントは Configure メソッドで Embeddings の設定をしているところです。AddAzureTextEmbeddingGenerationService で Embeddings の設定をしています。WithMemoryStorage で保存先を設定しています。今回はメモリ上に保存していますが DB などに保存するためのクラスもあります。

var kernel = Kernel.Builder
    .WithLogger(CreateConsoleLogger())
    .Configure(config =>
    {
        config.AddAzureTextEmbeddingGenerationService("embedding",
            "text-embedding-ada-002",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    // メモリ上に保存
    .WithMemoryStorage(new VolatileMemoryStore())
    .Build();

こうすると kernel.Memory で埋め込みを作成して保存するためのメソッドにアクセスできるようになります。SaveInformationAsync メソッドで任意のテキストを Embeddings に変換して保存できます。collection は保存先のコレクション名です。id は保存するテキストの ID です。text は保存するテキストです。description は保存するテキストの説明です。additionalMetadata は保存するテキストのメタデータです。


```csharp
    /// <summary>
    /// Save some information into the semantic memory, keeping a copy of the source information.
    /// </summary>
    /// <param name="collection">Collection where to save the information.</param>
    /// <param name="id">Unique identifier.</param>
    /// <param name="text">Information to save.</param>
    /// <param name="description">Optional description.</param>
    /// <param name="additionalMetadata">Optional string for saving custom metadata.</param>
    /// <param name="cancel">Cancellation token.</param>
    /// <returns>Unique identifier of the saved memory record.</returns>
    public Task<string> SaveInformationAsync(
        string collection,
        string text,
        string id,
        string? description = null,
        string? additionalMetadata = null,
        CancellationToken cancel = default);

実際に以下のようにしてデータを保存できます。

await kernel.Memory.SaveInformationAsync("Me", "私の名前は大田一希です。", "info1");
await kernel.Memory.SaveInformationAsync("Me", "私の生年月日は1981年1月30日です。", "info2");
await kernel.Memory.SaveInformationAsync("Me", "私は広島生まれです。", "info3");

保存したデータは SearchAsync メソッドで検索可能です。戻り値は IAsyncEnumerable<MemoryQueryResult> になります。
下のコードでは FirstOrDefaultAsync メソッドで最初の 1 件だけ取得しています。

MemoryQueryResult? result = await kernel.Memory.SearchAsync("Me", "あなたは何処出身ですか?").FirstOrDefaultAsync();

この MemoryQueryResult にどのようなデータが入っているのかを確認するため JSON 形式で表示してみました。

if (result is not null)
{
    Console.WriteLine(JsonSerializer.Serialize(result, options: new()
    {
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
        WriteIndented = true,
    }));
}

結果は以下のようになります。

{
  "Metadata": {
    "is_reference": false,
    "external_source_name": "",
    "id": "info3",
    "description": "",
    "text": "私は広島生まれです。",
    "additional_metadata": ""
  },
  "Relevance": 0.8665136038264644,
  "Embedding": null
}

「あなたは何処出身ですか?」という質問に対して「私は広島生まれです。」という結果が返ってきていますね。正直ドン引き!!凄いですね。

コードの全体は以下のようになります。

using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Memory;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

var kernel = Kernel.Builder
    .WithLogger(CreateConsoleLogger())
    .Configure(config =>
    {
        config.AddAzureTextEmbeddingGenerationService("embedding",
            "text-embedding-ada-002",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    // メモリ上に保存
    .WithMemoryStorage(new VolatileMemoryStore())
    .Build();

await kernel.Memory.SaveInformationAsync("Me", "私の名前は大田一希です。", "info1");
await kernel.Memory.SaveInformationAsync("Me", "私の生年月日は1981年1月30日です。", "info2");
await kernel.Memory.SaveInformationAsync("Me", "私は広島生まれです。", "info3");

MemoryQueryResult? result = await kernel.Memory.SearchAsync("Me", "あなたは何処出身ですか?").FirstOrDefaultAsync();
if (result is not null)
{
    Console.WriteLine(JsonSerializer.Serialize(result, options: new()
    {
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
        WriteIndented = true,
    }));
}

ILogger CreateConsoleLogger()
{
    return LoggerFactory.Create(builder =>
    {
        builder.AddFilter(_ => true).AddConsole();
    }).CreateLogger<Program>();
}

永続化してみよう

Semantic Kernel では 2023 年 5 月 5 日現在では Sqlite と Qdrant に対応しています。今回は Qdrant (なんて発音するの?) を試してみようと思います。Qdrant は以下のページを参考に docker 上で実行してみました。

https://zenn.dev/tfutada/articles/acf8adbb2ba5be

実際に起動するために実行したコマンドは以下のようになります。

> docker pull qdrant/qdrant
> docker run -p 6333:6333 -v c:\temp\qdrant_strage:/qdrant/storage qdrant/qdrant

そして Microsoft.SemanticKernel.Connectors.Memory.Qdrant パッケージを追加してプログラムを以下のように書き換えます。

using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Memory.Qdrant;
using Microsoft.SemanticKernel.Memory;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

var kernel = Kernel.Builder
    .WithLogger(CreateConsoleLogger())
    .Configure(config =>
    {
        config.AddAzureTextEmbeddingGenerationService("embedding",
            "text-embedding-ada-002",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    // メモリ上に保存
    .WithMemoryStorage(new QdrantMemoryStore("http://localhost", 6333, 1536, CreateConsoleLogger()))
    .Build();

await kernel.Memory.SaveInformationAsync("Me", "私の名前は大田一希です。", "info1");
await kernel.Memory.SaveInformationAsync("Me", "私の生年月日は1981年1月30日です。", "info2");
await kernel.Memory.SaveInformationAsync("Me", "私は広島生まれです。", "info3");

MemoryQueryResult? result = await kernel.Memory.SearchAsync("Me", "あなたは何処出身ですか?").FirstOrDefaultAsync();
if (result is not null)
{
    Console.WriteLine(JsonSerializer.Serialize(result, options: new()
    {
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
        WriteIndented = true,
    }));
}

ILogger CreateConsoleLogger()
{
    return LoggerFactory.Create(builder =>
    {
        builder.AddFilter(_ => true).AddConsole();
    }).CreateLogger<Program>();
}

実行結果は前と変わらないので割愛しますが、ブラウザーで http://localhost:6333/collections/Me にアクセスすると以下のような結果が表示されました。ちゃんと何かしら表示されていそうです。

{
    "result": {
        "status": "green",
        "optimizer_status": "ok",
        "vectors_count": 3,
        "indexed_vectors_count": 0,
        "points_count": 3,
        "segments_count": 8,
        "config": {
            "params": {
                "vectors": {
                    "size": 1536,
                    "distance": "Cosine"
                },
                "shard_number": 1,
                "replication_factor": 1,
                "write_consistency_factor": 1,
                "on_disk_payload": true
            },
            "hnsw_config": {
                "m": 16,
                "ef_construct": 100,
                "full_scan_threshold": 10000,
                "max_indexing_threads": 0,
                "on_disk": false
            },
            "optimizer_config": {
                "deleted_threshold": 0.2,
                "vacuum_min_vector_number": 1000,
                "default_segment_number": 0,
                "max_segment_size": null,
                "memmap_threshold": null,
                "indexing_threshold": 20000,
                "flush_interval_sec": 5,
                "max_optimization_threads": 1
            },
            "wal_config": {
                "wal_capacity_mb": 32,
                "wal_segments_ahead": 0
            },
            "quantization_config": null
        },
        "payload_schema": {}
    },
    "status": "ok",
    "time": 0.000044539
}

プログラムをちょっと変更してデータの保存をしないで、いきなり検索をするようにします。

using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Memory.Qdrant;
using Microsoft.SemanticKernel.Memory;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;

var kernel = Kernel.Builder
    .WithLogger(CreateConsoleLogger())
    .Configure(config =>
    {
        config.AddAzureTextEmbeddingGenerationService("embedding",
            "text-embedding-ada-002",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    // メモリ上に保存
    .WithMemoryStorage(new QdrantMemoryStore("http://localhost", 6333, 1536, CreateConsoleLogger()))
    .Build();

//await kernel.Memory.SaveInformationAsync("Me", "私の名前は大田一希です。", "info1");
//await kernel.Memory.SaveInformationAsync("Me", "私の生年月日は1981年1月30日です。", "info2");
//await kernel.Memory.SaveInformationAsync("Me", "私は広島生まれです。", "info3");

MemoryQueryResult? result = await kernel.Memory.SearchAsync("Me", "何時生まれたの?").FirstOrDefaultAsync();
if (result is not null)
{
    Console.WriteLine(JsonSerializer.Serialize(result, options: new()
    {
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
        WriteIndented = true,
    }));
}

ILogger CreateConsoleLogger()
{
    return LoggerFactory.Create(builder =>
    {
        builder.AddFilter(_ => true).AddConsole();
    }).CreateLogger<Program>();
}

「何時生まれたの?」と個人的には微妙なラインを攻めてみたのですが結果は以下のようになりました。

{
  "Metadata": {
    "is_reference": false,
    "external_source_name": "",
    "id": "info3",
    "description": "",
    "text": "私は広島生まれです。",
    "additional_metadata": ""
  },
  "Relevance": 0.85852367,
  "Embedding": null
}

うん。生年月日は返ってきませんでした。まぁ仕方ないよね。因みに「なんて呼ばれてるの?」という問いかけをすると以下の結果になりました。こっちはちゃんと正しいのが返ってきますね。

{
  "Metadata": {
    "is_reference": false,
    "external_source_name": "",
    "id": "info1",
    "description": "",
    "text": "私の名前は大田一希です。",
    "additional_metadata": ""
  },
  "Relevance": 0.83195853,
  "Embedding": null
}

このように、永続化してしまえば当然ですが登録作業は 1 回で済んで次からは検索することができます。いい感じですね。

テンプレートから使おう

因みに Semantic Kernel が提供しているネイティブ スキル(組み込みスキル)の中に TextMemorySkill というものがあります。これを使うとメモリ上からテキストを検索してくるということがテンプレートで出来るようになります。TextMemorySkill には Recall という関数があって、これが検索をしてくるものになります。

using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Memory.Qdrant;
using Microsoft.SemanticKernel.CoreSkills;
using Microsoft.SemanticKernel.TemplateEngine;

var kernel = Kernel.Builder
    .WithLogger(CreateConsoleLogger())
    .Configure(config =>
    {
        config.AddAzureTextEmbeddingGenerationService("embedding",
            "text-embedding-ada-002",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    // メモリ上に保存
    .WithMemoryStorage(new QdrantMemoryStore("http://localhost", 6333, 1536, CreateConsoleLogger()))
    .Build();

// recall が使えるように TextMemorySkill をインポート
kernel.ImportSkill(new TextMemorySkill());

// recall 関数を使うテンプレート
const string prompt = """"
    あなたはパーソナル アシスタントです。以下のやり取りを参考に質問に対して回答してください。

    -----やりとり-----
    """なんて呼ばれてるの?""" という問いには """{{recall 'なんて呼ばれてるの?'}}""" と答えてください。
    """あなたは何処出身ですか?""" という問いには """{{recall 'あなたは何処出身ですか?'}}""" と答えてください。
    -----------------

    問い: {{$input}}
    回答: 
    """";

// とりあえずテンプレートをレンダリングしてみる
var input = kernel.CreateNewContext();
input.Variables.Update("あなたの名前は何ですか?");
input.Variables.Set("collection", "Me");

var templateEngine = new PromptTemplateEngine();
var result = await templateEngine.RenderAsync(prompt, input);
Console.WriteLine(result);

ILogger CreateConsoleLogger()
{
    return LoggerFactory.Create(builder =>
    {
        builder.AddFilter(_ => true).AddConsole();
    }).CreateLogger<Program>();
}

テンプレートのレンダリング結果は以下のようになりました。ちゃんと、メモリ上に保存した情報を検索してきてテンプレートに展開してくれていますね。

あなたはパーソナル アシスタントです。以下のやり取りを参考に質問に対して回答してください。

-----やりとり-----
"""なんて呼ばれてるの?""" という問いには """私の名前は大田一希です。""" と答えてください。
"""あなたは何処出身ですか?""" という問いには """私は広島生まれです。""" と答えてください。
-----------------

問い: あなたの名前は何ですか?
回答:

ドキュメントも登録したい

ドキュメントの登録にも対応しています。というか、URL などのようなドキュメントのロケーションとソースの名前を追加で渡せるようになっています。これは text パラメーターにドキュメントの要約を渡してあげることになると思うので OpenAI とかで要約してもらうのが良さそうですね。もしくは分割か。

    /// <summary>
    /// Save some information into the semantic memory, keeping only a reference to the source information.
    /// </summary>
    /// <param name="collection">Collection where to save the information.</param>
    /// <param name="text">Information to save.</param>
    /// <param name="externalId">Unique identifier, e.g. URL or GUID to the original source.</param>
    /// <param name="externalSourceName">Name of the external service, e.g. "MSTeams", "GitHub", "WebSite", "Outlook IMAP", etc.</param>
    /// <param name="description">Optional description.</param>
    /// <param name="additionalMetadata">Optional string for saving custom metadata.</param>
    /// <param name="cancel">Cancellation token</param>
    /// <returns>Unique identifier of the saved memory record.</returns>
    public Task<string> SaveReferenceAsync(
        string collection,
        string text,
        string externalId,
        string externalSourceName,
        string? description = null,
        string? additionalMetadata = null,
        CancellationToken cancel = default);

これは、ドキュメントなどを用意するのがめんどくさかったので公式のサンプルをそのまま拝借してきて、以下のように今回のサンプルに合わせて少し変えて試してみました。

using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Memory.Qdrant;
using Microsoft.SemanticKernel.Memory;

var kernel = Kernel.Builder
    .WithLogger(CreateConsoleLogger())
    .Configure(config =>
    {
        config.AddAzureTextEmbeddingGenerationService("embedding",
            "text-embedding-ada-002",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    // メモリ上に保存
    .WithMemoryStorage(new QdrantMemoryStore("http://localhost", 6333, 1536, CreateConsoleLogger()))
    .Build();

const string memoryCollectionName = "SKGitHub";

var githubFiles = new Dictionary<string, string>()
{
    ["https://github.com/microsoft/semantic-kernel/blob/main/README.md"]
        = "README: Installation, getting started, and how to contribute",
    ["https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/02-running-prompts-from-file.ipynb"]
        = "Jupyter notebook describing how to pass prompts from a file to a semantic skill or function",
    ["https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/00-getting-started.ipynb"]
        = "Jupyter notebook describing how to get started with the Semantic Kernel",
    ["https://github.com/microsoft/semantic-kernel/tree/main/samples/skills/ChatSkill/ChatGPT"]
        = "Sample demonstrating how to create a chat skill interfacing with ChatGPT",
    ["https://github.com/microsoft/semantic-kernel/blob/main/dotnet/src/SemanticKernel/Memory/VolatileMemoryStore.cs"]
        = "C# class that defines a volatile embedding store",
    ["https://github.com/microsoft/semantic-kernel/blob/main/samples/dotnet/KernelHttpServer/README.md"]
        = "README: How to set up a Semantic Kernel Service API using Azure Function Runtime v4",
    ["https://github.com/microsoft/semantic-kernel/blob/main/samples/apps/chat-summary-webapp-react/README.md"]
        = "README: README associated with a sample chat summary react-based webapp",
};

// ========= Store memories =========

Console.WriteLine("Adding some GitHub file URLs and their descriptions to a volatile Semantic Memory.");
var i = 0;
foreach (var entry in githubFiles)
{
    await kernel.Memory.SaveReferenceAsync(
        collection: memoryCollectionName,
        description: entry.Value,
        text: entry.Value,
        externalId: entry.Key,
        externalSourceName: "GitHub"
    );
    Console.WriteLine($"  URL {++i} saved");
}

Console.WriteLine("Files added.");

/*
Output:

Adding some GitHub file URLs and their descriptions to a volatile Semantic Memory.
    URL 1 saved
    URL 2 saved
    URL 3 saved
    URL 4 saved
    URL 5 saved
    URL 6 saved
    URL 7 saved
Files added.
*/

// ========= Search semantic memory #1 =========

string ask = "How do I get started?";
Console.WriteLine("===========================\n" +
                    "Query: " + ask + "\n");

var memories = kernel.Memory.SearchAsync(memoryCollectionName, ask, limit: 5, minRelevanceScore: 0.77);

i = 0;
await foreach (MemoryQueryResult memory in memories)
{
    Console.WriteLine($"Result {++i}:");
    Console.WriteLine("  URL:     : " + memory.Metadata.Id);
    Console.WriteLine("  Title    : " + memory.Metadata.Description);
    Console.WriteLine("  Relevance: " + memory.Relevance);
    Console.WriteLine();
}


ILogger CreateConsoleLogger()
{
    return LoggerFactory.Create(builder =>
    {
        builder.AddFilter(_ => true).AddConsole();
    }).CreateLogger<Program>();
}

GitHub の Semantic Kernel のリポジトリの中身を登録して、検索しているといった流れになっていますね。実行結果は以下のようになりました。(実際にはもっと細かいログが出ていますが、それは省いて Console.WriteLine で出力されたものだけにしています)

Adding some GitHub file URLs and their descriptions to a volatile Semantic Memory.
  URL 1 saved
  URL 2 saved
  URL 3 saved
  URL 4 saved
  URL 5 saved
  URL 6 saved
  URL 7 saved
Files added.
===========================
Query: How do I get started?

dbug: Program[0]
      Searching top 5 nearest vectors
trce: Program[0]
      Qdrant responded successfully
Result 1:
  URL:     : https://github.com/microsoft/semantic-kernel/blob/main/README.md
  Title    : README: Installation, getting started, and how to contribute
  Relevance: 0.82429326

Result 2:
  URL:     : https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/00-getting-started.ipynb
  Title    : Jupyter notebook describing how to get started with the Semantic Kernel
  Relevance: 0.79267204

いい感じですね。

まとめ

Semantic Kernel を使って Embeddings を試してみました。
永続化まで面倒を見てくれるので素の SDK を使うよりも簡単に使えるので、これはいいですね。
NuGet にはないですが GitHub 上には Qdrant と Sqlite 以外にも Cosmos DB や Azure Cognitive Search などのコネクタもコードは存在するので将来的には Azure のマネージドサービスだけで永続化までいけるようになるといいなぁと思いました。

Microsoft (有志)

Discussion