💬

Semantic Kernel でトークンの限界を超えるような長い文章を分割してスキルに渡して結果を結合したい

2023/05/06に公開

ありがちなシチュエーションですね。
試しに一気に渡すとダメなケースを試してみましょう。

using Azure.Identity;
using Microsoft.SemanticKernel;

var kernel = Kernel.Builder
    .Configure(config =>
    {
        config.AddAzureTextCompletionService("service1",
            "kaota-text-davinci-003",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    .Build();

var summarize = kernel.CreateSemanticFunction("""
    日本の古典の一説を現代語で要約して。

    <古典の文章>
    {{$input}}
    </古典の文章>

    要約:

    """,
    maxTokens: 3072);

var result = summarize.InvokeAsync(Isemonogatari.AzumaKudari);
Console.WriteLine(result);

static class Isemonogatari
{
    // こちらから引用させていただきました
    // https://shikinobi.com/isemonogatari-2
    public const string AzumaKudari = """
        昔、男ありけり。その男、身を要なきものに思ひなして、
        「京にはあらじ、東の方に住むべき国求めに。」
        とて行きけり。
        もとより友とする人、一人二人して行きけり。
        道知れる人もなくて、惑ひ行きけり。
        三河の国、八橋といふ所にいたりぬ。

        そこを八橋といひけるは、水ゆく河の蜘蛛手なれば、橋を八つ渡せるによりてなむ、八橋といひける。
        その沢のほとりの木の陰に下りゐて()、乾飯(かれいひ)食ひけり。
        その沢にかきつばたいとおもしろく咲きたり。

        それを見て、ある人のいはく、
        「かきつばたといふ五文字(いつもじ)を、句の上(かみ)に据ゑて、旅の心を詠め。」
        と言ひければ、詠める。
        [唐衣きつつなれにしつましあれば はるばるきぬる旅をしぞ思ふ]
        と詠めりければ、みな人、乾飯の上に涙落として、ほとびにけり。

        行き行きて駿河の国に至りぬ。
        宇津の山に至りて、わが入らむとする道は、いと暗う細きに、蔦(つた)、楓(かへで)は茂り、もの心細く、すずろなるめを見ることと思ふに、修行者会ひたり。
        「かかる道は、いかでかいまする。」
        と言ふを見れば、見し人なりけり。
        京に、その人の御もとにとて、文書きてつく。
        [駿河なる宇津の山べのうつつにも 夢にも人にあはぬなりけり]
        富士の山を見れば、五月のつごもりに、雪いと白う降れり。
        [時知らぬ山は富士の嶺()いつとてか 鹿の子まだらに雪の降るらむ]
        その山は、ここにたとへば、比叡の山を二十ばかり重ね上げたらむほどして、なりは塩尻のやうになむありける。

        なほ行き行きて、武蔵の国と下総の国との中に、いと大きなる川あり。
        それをすみだ川といふ。
        その川のほとりに群れゐて、
        「思ひやれば、限りなく遠くも来にけるかな。」
        とわび合へるに、渡し守、
        「はや舟に乗れ。日も暮れぬ。」
        と言ふに、乗りて渡らむとするに、みな人ものわびしくて、京に思ふ人なきにしもあらず。

        さる折しも、白き鳥の嘴(はし)と脚と赤き、鴫(しぎ)の大きさなる、水の上に遊びつつ魚(いを)を食ふ。

        京には見えぬ鳥なれば、みな人見知らず。
        渡し守に問ひければ、
        「これなむ都鳥」
        と言ふを聞きて、
        [名にし負はばいざこと問はむ都鳥 わが思ふ人はありやなしやと]
        と詠めりければ、舟こぞりて泣きにけり。
        """;
}

結果は以下のようになりました。ダメです。

Error: InvalidRequest: The request is not valid, HTTP status: 400

Semantic Kernel には TextChunker という長い文字列を分割してくれる機能があります。
このクラスですが 2023/05/06 時点で最新の 0.13.277.1-preview には SemanticTextPartitioner という名前になっていますが GitHub の最新コードでは TextChunker になっているので、そちらを使おうと思います。さらに、現時点ではトークンのカウントが英語前提の適当実装 (文字列の Length / 4) になっています。TODO コメントもあるので将来的にはちゃんとしたトークンのカウントになると思うのですが現時点では TextChunker のコードをコピペしてきてトークンのカウント処理を以下のように書き換えておきました。

    // オリジナル
    //private static int TokenCount(int inputLength)
    //{
    //    // TODO: partitioning methods should be configurable to allow for different tokenization strategies
    //    //       depending on the model to be called. For now, we use an extremely rough estimate.
    //    return inputLength / 4;
    //}

    // とりあえず実装
    private static int TokenCount(int inputLength)
    {
        // 本来は文字列を受け取って真面目にトークンをカウントしないとダメだと思う
        return inputLength;
    }

これで TextChunker を使ってみます。

// 1 行を 50 トークンを上限で分割して 1 段落を 200 トークンを上限に分割する
var paragraphs = TextChunker.SplitPlainTextParagraphs(TextChunker.SplitPlainTextLines(Isemonogatari.AzumaKudari, 50), 200);
Console.WriteLine(paragraphs.Count);

実行すると 6 と表示されました。とりあえずいい感じかどうかは置いておいて分割されました。テキストが分割されたら、あとはスキルに渡して要約をしてもらうといった流れになるのですが、これを簡略化するための AggregatePartitionedResultsAsync 拡張メソッドが提供されています。

実装は至ってシンプルで以下のようになっています。順次ループして結果を集約しているだけです。

// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.SemanticKernel.Orchestration;

namespace Microsoft.SemanticKernel.SemanticFunctions.Partitioning;

/// <summary>
/// Class with extension methods for semantic functions.
/// </summary>
public static class FunctionExtensions
{
    /// <summary>
    /// Extension method to aggregate partitioned results of a semantic function.
    /// </summary>
    /// <param name="func">Semantic Kernel function</param>
    /// <param name="partitionedInput">Input to aggregate.</param>
    /// <param name="context">Semantic Kernel context.</param>
    /// <returns>Aggregated results.</returns>
    public static async Task<SKContext> AggregatePartitionedResultsAsync(
        this ISKFunction func,
        List<string> partitionedInput,
        SKContext context)
    {
        var results = new List<string>();
        foreach (var partition in partitionedInput)
        {
            context.Variables.Update(partition);
            context = await func.InvokeAsync(context);

            results.Add(context.Variables.ToString());
        }

        context.Variables.Update(string.Join("\n", results));
        return context;
    }
}

これを使うと以下のように書けます。

using Azure.Identity;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.SemanticFunctions.Partitioning;
using Microsoft.SemanticKernel.Text;

var kernel = Kernel.Builder
    .Configure(config =>
    {
        config.AddAzureTextCompletionService("service1",
            "kaota-text-davinci-003",
            "https://aikaota.openai.azure.com/",
            // マネージド ID で認証。API Key の人はここに API Key の文字列を渡す
            new DefaultAzureCredential(options: new() { ExcludeVisualStudioCredential = true }));
    })
    .Build();

// スキル作成
var summarize = kernel.CreateSemanticFunction("""
    日本の古典の一説を現代語で要約して。

    <古典の文章>
    {{$input}}
    </古典の文章>

    要約:

    """,
    maxTokens: 3072);

// 分割
var paragraphs = TextChunker.SplitPlainTextParagraphs(TextChunker.SplitPlainTextLines(Isemonogatari.AzumaKudari, 50), 200);
// 実行
var result = await summarize.AggregatePartitionedResultsAsync(paragraphs, kernel.CreateNewContext());
Console.WriteLine(result);


static class Isemonogatari
{
    public const string AzumaKudari = """
        ...長いので省略...
        """;
}

実行すると以下のようになりました。古典とかは GPT3 だと全然ダメかと思ったんですが「かきつばた」のエピソードあたりは昔教科書で習ったようなことが書いてあるのでまぁまぁ出来てるっぽいですね。

ある男が、身を要なくして東の方に住むべき国を求めて旅に出た。友人を誘い、道知れぬまま惑いながら旅をした結果、三河の国の八橋という所にたどり着いた。そこを八橋というのは、水ゆく河を蜘蛛手ならば八つ橋を渡せるからである。
ある人が沢のほとりの木の陰で乾飯を食べていると、かきつばたが咲いているのを見て、「かきつばたという五文字を句の上に据えて、旅の心を詠め」と言った。すると、「唐衣きつつなれにしつましあれば、はるばるきぬる旅をしぞ思ふ」と詠われたところ、みんなが涙を流して感動した。
駿河の国に行ったとき、宇津の山に辿り着いたとき、蔦や楓が茂り、深い暗闇の中を歩いていると、修行者が会った。その修行者が「この道はどこに行くのか?」と聞いたところ、その人は京都に行き、そこで文書を書いた。その文書には「駿河なる宇津の山べのうつつにも夢にも人にあはぬなりけり」とあった。
五月に富士の山を見ると、雪が白く降り注ぐ。その山は比叡の山を20個重ねたような大きさで、その周辺には大きな川、すみだ川があり、その川のほとりには群れがいる。
思いを限りなく遠くに向けると、渡し守が「はや舟に乗れ。日も暮れぬ」と言って乗り出した。そこには、京には見えない白い鳥の嘴と脚、赤い鴫の大きさで水の上を遊びながら魚を食べる鳥がいて、みんな見知らずだった。渡し守に尋ねると、「これなむ都鳥」と答えた。
都鳥が歌ったとき、舟に乗って泣いている人がいるかいないかを問うと、私は思うにあると詠えた。

まとめ

ということでトークンの制限を超えるような長いものを渡すケースでは分割して渡すというのが 1 つのセオリーです。
分割の部分は TextChunker というクラスが使えますが現時点では日本語では使い物にならないので自分でどうにか分割しましょう。今回は TextChunker をコピペして少し手を入れて使いました。

分割したテキストをセマンティック スキルに渡す方法は ISKFunctionAggregatePartitionedResultsAsync という拡張メソッドを使うと簡単に書けます。ただ、分割数が多いと API をシーケンシャルに呼び出しているだけなので結構時間がかかります。分割数が多い場合などは素直にこのメソッドを使うよりは自分でループして呼び出して、進捗を適宜ユーザーにフィードバックしたほうが良いかもしれません。

Microsoft (有志)

Discussion