個人的なメモ

Tomohiro Suzuki @hiro128_777 のブログです。Microsoft MVP for Developer Technologies 2017- 本ブログと所属組織の公式見解は関係ございません。

C# だけで Azure OpenAI の RAG を試してみたい (5)Blob コンテナ上でのソースデータの更新日時の管理

 
前の記事
hiro128.hatenablog.jp
 

留意点②:Blob コンテナ上でのソースデータの更新日時の管理

 
このサンプルではストレージアカウントの SKU は以下の通りとなっています。
 

 
Blob 側の最終変更日は、コンテナにファイルがアップロードされた日時なので、
コンテナの項目のメタデータにソース記事の url とはてなブログの更新日時を記録しておきます。

 

Blob メタデータへ url と 最終更新日時を付加したうえで、Blob 上の既存項目から更新があればアップロードする

上述のとおり正しくインデックスのドキュメントを更新するためには、はてなブログでの更新日時が必要なため、Blob にアップロードする際に、はてなブログの当該記事の url と 最終更新日時をメタデータとして付与します。
 
作成したソースコードの詳細は以下を参照ください。
github.com
 
Blob 項目をアップロードするコードは以下の通りです。

        async ValueTask UploadOrUpdateAzureBlobItem(HatenaBlogEntry entry, BlobClient blobClient)
        {
            var htmlStream = await httpClient.GetStreamAsync(entry.Url);
            var options = new BlobUploadOptions
            {
                HttpHeaders = new BlobHttpHeaders
                {
                    ContentType = "text/html",
                },
                Metadata = new Dictionary<string, string>
                {
                    { "url", entry.Url },
                    { "lastUpdated", entry.LastUpdated.ToString()}
                }
            };
            await blobClient.UploadAsync(htmlStream, options);
        }

 
はてなブログの記事を Blob にアップロードする際に新規記事は無条件にアップロードし、既存記事については Blob のメタデータに記録したアーカイブ済の Blob 項目の更新日時を確認し、変更があるもののみ再アップロードする必要があります。
 
よって、既存の Blob 項目の存在確認を実施し、日付を見てアップロードすべきもののみアップロードする制御を入れています。
また、関数アプリのホスティングプランは「従量課金」にしているので、失敗しないように uploadLimit で1回あたりの処理数を制限しています。
これらの処理については以下を参照ください。
 

        // はてなブログの記事をAzure Blob Storageにアップロードする
        // (多すぎるとインデックス作成が失敗するので uploadLimit の設定値まで)
        async ValueTask UpdateAzureBlobContainerItems(List<HatenaBlogEntry> entries)
        {
            var currentUploadCount = 0;
            foreach (var (entry, index) in entries.Select((entry, index) => (entry, index)))
            {
                if (currentUploadCount >= uploadLimit)
                {
                    logger.LogInformation($"Upload limit reached. {uploadLimit} articles uploaded.");
                    break;
                }
                var blobName = entry.Url.Replace($"https://{hatenaID}.hatenablog.jp/entry/", "").Replace("/", "-") + ".html";
                var blobClient = blobContainerClient.GetBlobClient(blobName);
                bool exists = await blobClient.ExistsAsync();
                if (!exists)
                {
                    logger.LogInformation($"Upload : {(index + 1).ToString("000000")}/{entries.Count.ToString("000000")} {entry.Url}");
                    await UploadOrUpdateAzureBlobItem(entry, blobClient);
                    currentUploadCount++;
                }
                else
                {
                    var response = await blobClient.DownloadAsync();
                    if (!string.IsNullOrEmpty(response.Value.Details.Metadata["lastUpdated"]))
                    {
                        var azureBlogLastUpdated = DateTimeOffset.Parse(response.Value.Details.Metadata["lastUpdated"]);

                        // 面倒なのでタイムスタンプが一致しているときのみスキップ
                        // つまり、はてな側の更新日が古くても上書きする
                        // 理由:齟齬があるのは間違いなく、はてな側を正とすべきなのは何ら変わらないから
                        if (entry.LastUpdated == azureBlogLastUpdated)
                        {
                            logger.LogInformation($"Skip   : {(index + 1).ToString("000000")}/{entries.Count.ToString("000000")} {entry.Url}");
                            continue;
                        }
                    }

                    logger.LogInformation($"Upload : {(index + 1).ToString("000000")}/{entries.Count.ToString("000000")} {entry.Url}");
                    await UploadOrUpdateAzureBlobItem(entry, blobClient);
                    currentUploadCount++;
                }
            }
        }

  
次の記事
hiro128.hatenablog.jp

C# だけで Azure OpenAI の RAG を試してみたい (4)html ファイルのクレンジング

 
前の記事
hiro128.hatenablog.jp
 

html ファイルのクレンジング

 
作成したソースコードの詳細は以下を参照ください。
github.com
 

どのような実装をしたのか

はてなブログのソース html はベクトルインデックスを作成する観点ではかなり余計な部分が多いので、まずは、以下のような余計な部分を除去する必要があります。

  • 余計な改行
  • コンテンツ内容に関係ないヘッダ、メタデータ、はてなブログのナビゲーション関係などのタグ

これらをすべて実装できれば良いのですが今回は最低限度の実装として、参考にした以下の Python サンプルの実装を参考に parser のみを C# に移植しました。
具体的には、インデックスに登録するドキュメントには title フィールドと content フィールドが必要なので、タイトルと記事の文章を抽出しなるべくゴミを除去する機能を実装しています。
(本当に最低限度なので、改良の余地は多いにあります。)
 

参考にした Python のサンプルの実装

参考にした Python のサンプルの実装は以下の部分です。
github.com
 

移植した C# の実装

移植した C# の実装は以下の通りです。
 

sing AngleSharp;
using AngleSharp.Dom;
using System.Text.RegularExpressions;

namespace IndexCreator
{
    public abstract class CleanserBase
    {
        public abstract (string, string) Cleanse(string content, string fileName = "");

        public static string CleanupContent(string content)
        {
            var output = content;
            output = Regex.Replace(output, @"[^\S\n]{2,}", " ", RegexOptions.Multiline);
            output = Regex.Replace(output, @"-{2,}", "--", RegexOptions.Multiline);
            output = Regex.Replace(output, @"^\s$\n", "\n", RegexOptions.Multiline);
            output = Regex.Replace(output, @"\n{2,}", "\n", RegexOptions.Multiline);

            return output.Trim();
        }

        string GetFirstLineWithProperty(string content, string property = "title: ")
        {
            foreach (string line in content.Split(Environment.NewLine))
            {
                if (line.StartsWith(property))
                    return line.Substring(property.Length).Trim();
            }
            return string.Empty;
        }

        string GetFirstAlphanumLine(string content)
        {
            foreach (string line in content.Split(Environment.NewLine))
            {
                if (line.Any(char.IsLetterOrDigit))
                    return line.Trim();
            }
            return string.Empty;
        }
    }

    public class TextCleanser : CleanserBase
    {
        public TextCleanser() : base() { }

        string GetFirstAlphanumLine(string content)
        {
            foreach (string line in content.Split(Environment.NewLine))
            {
                if (line.Any(c => Char.IsLetterOrDigit(c)))
                    return line.Trim();
            }
            return string.Empty;
        }

        string GetFirstLineWithProperty(string content, string property = "title: ")
        {
            foreach (string line in content.Split(Environment.NewLine))
            {
                if (line.StartsWith(property))
                    return line.Substring(property.Length).Trim();
            }
            return string.Empty;
        }

        public override (string, string) Cleanse(string content, string fileName = "")
        {
            string title = GetFirstLineWithProperty(content) ?? GetFirstAlphanumLine(content);
            return (CleanupContent(content), title ?? fileName);
        }
    }

    public class HtmlCleanser : CleanserBase
    {
        static readonly int titleMaxTokens = 128;
        TokenEstimator tokenEstimator;

        public HtmlCleanser() : base()
        {
            tokenEstimator = new TokenEstimator();
        }

        public override (string, string) Cleanse(string content, string fileName = "")
        {
            var title = ExtractTitle(content, fileName);
            return (CleanupContent(content), title);
        }

        string ExtractTitle(string content, string fileName)
        {
            var context = BrowsingContext.New(Configuration.Default);
            var document = context.OpenAsync(req => req.Content(content)).Result;

            var titleElement = document.QuerySelector("title");
            if (titleElement != null)
            {
                return titleElement.TextContent;
            }

            var h1Element = document.QuerySelector("h1");
            if (h1Element != null)
            {
                return h1Element.TextContent;
            }

            var h2Element = document.QuerySelector("h2");
            if (h2Element != null)
            {
                return h2Element.TextContent;
            }

            //if title is still not found, guess using the next string
            var title = GetNextStrippedString(document);
            title = tokenEstimator.ConstructTokensWithSize(title, titleMaxTokens);

            if (string.IsNullOrEmpty(title))
                title = fileName;

            return title;
        }

        public string GetNextStrippedString(IDocument document)
        {
            var allTextNodes = document.All
                .Where(n => n.NodeType == NodeType.Text && n.TextContent.Trim() != "")
                .Select(n => n.TextContent.Trim());
            // Get the first non-empty string
            var nextStrippedString = allTextNodes.FirstOrDefault() ?? "";
            return nextStrippedString;
        }
    }

}

 
次の記事
hiro128.hatenablog.jp

C# だけで Azure OpenAI の RAG を試してみたい (3)TokenCounter の実装

 
前の記事
hiro128.hatenablog.jp
 

TokenCounter の実装

SplitPlainTextParagraphs メソッドで正しくトークンをカウントするために必要な tokenCounter パラメーターはソースコードを確認すると、string を受け取り、int を返すデリゲートになっており、文字列内のトークンをカウントする関数を与える必要があります。指定しない場合は、デフォルトのカウンターが使用されますが、意図しないカウントをされてしまう可能性があります。

よって、自分がデプロイした Enbedding モデルに適合したトークンカウンターのデリゲートを渡すようにします。
トークンカウンターは、ベクトルインデックス作成時の embedding モデルに合わせる必要があります。
デリゲートは、Tokenizer のライブラリを利用して作成します。
 

Tokenizer のライブラリ

Tokenizer のライブラリは Python では https://github.com/openai/tiktoken がスタンダードです。
github.com
 
C# には tiktoken をもとにした C# 実装の Microsoft.DeepDev.TokenizerLib がありますので、これを利用します。
github.com
 

作成した C# のソースコード

作成した C# のソースコードの詳細は以下の GitHub または下記を参照ください。EstimateTokens メソッドがトークンカウンターです。
(※クラスにはこのサンプルで使用する別のメソッドも実装してあります)
github.com

using Microsoft.DeepDev;

namespace IndexCreator
{
    public class TokenEstimator
    {
        ITokenizer tokenizer;

        public TokenEstimator(string modelToEncoding = "text-embedding-ada-002")
        {
            BuildTokenizer(modelToEncoding).Wait();
            if (tokenizer == null)
                throw new Exception("Tokenizer is not built");
        }

        async Task BuildTokenizer(string modelToEncoding)
        {
            tokenizer = await TokenizerBuilder.CreateByModelNameAsync(modelToEncoding);
        }

        public int EstimateTokens(string text)
        {
            return tokenizer.Encode(text).Count;
        }

        public string ConstructTokensWithSize(string tokens, int numberOfTokens)
        {
            var encodedTokens = tokenizer.Encode(tokens, Array.Empty<string>());
            var newTokens = tokenizer.Decode(encodedTokens.Take(numberOfTokens).ToArray());
            return newTokens;
        }
    }
}

 
次の記事
hiro128.hatenablog.jp

C# だけで Azure OpenAI の RAG を試してみたい (2)C# によるテキストのクレンジング・チャンクの方針

 
前の記事
hiro128.hatenablog.jp
 

C# のみで RAG を構築する場合の留意点

  1. C# では Python と比べて日本語の言語処理にやチャンク(テキスト分割)に関して圧倒的にライブラリが少ないが、html ファイルのクレンジングやテキストをチャンクする際のトークンカウンターをどうするか。
  2. ソースのブログ記事が更新されたとき、インデックス上の当該ドキュメントを削除する判断基準となるはてなブログ記事の更新日時の管理はどうするか。
  3. インデックスはキー属性が付与された値でしか削除できないので、ソースデータ更新時にどうやって削除するか。

 

留意点①:C# によるテキストのクレンジング・チャンク

C# では Python と比べて日本語の言語処理にやチャンク(テキスト分割)に関して圧倒的にライブラリが少ないが、html ファイルのクレンジングやテキストをチャンクする際のトークンカウンターをどうするか検討したところ、以下の方法がありそうです。

  1. 現状提供されている SDK/ライブラリでどうにかする
    • Python にあるようなリッチなライブラリは現状ない
      • markdown とプレーンテキストならプレビューではあるが、 Microsoft.SemanticKernel.TextChunker が対応している
  2. Python の処理を C# に移植する
    • Python の標準ライブラリは言語処理関連が充実しているが、必要な処理すべてを C# に移植するのは量的に困難
      • 現状提供されているライブラリがない機能を移植する

1 をメインに機能が足りない部分は 2 も併用する。
 

SemanticKernel.TextChunker を使う

SemanticKernel.TextChunker の公式ドキュメントは以下を参照ください。
learn.microsoft.com


提供メソッドは以下の4種類(現状 markdown とプレーンテキストのみ対応)

SplitMarkDownLines(String, Int32, TextChunker.TokenCounter) markdownテキストを行に分割します。
SplitMarkdownParagraphs(List<String>, Int32, Int32, String, TextChunker.TokenCounter) markdownテキストを段落に分割します。
SplitPlainTextLines(String, Int32, TextChunker.TokenCounter) プレーンテキストを行に分割します。
SplitPlainTextParagraphs(List<String>, Int32, Int32, String, TextChunker.TokenCounter) プレーンテキストを段落に分割します。

html からプレーンテキストを抽出およびクレンジングし、③, ④のメソッドでチャンクする方針とします。
 

SplitPlainTextParagraphs メソッドの留意点

パラメータ名 説明
lines List<String> テキストの行。
maxTokensPerParagraph Int32 段落ごとのトークンの最大数。
overlapTokens Int32 段落間で重なるトークンの数。
chunkHeader String 個々のチャンクの前に追加されるテキスト。
tokenCounter delegate int TokenCounter(string input) 文字列内のトークンをカウントする関数。指定しない場合は、デフォルトのカウンターが使用される。

正しくトークンをカウントする必要があるので、ポイントになるのが、TokenCounter ですが、この実体はソースコードを確認すると、string をパラメータとし、int を返すデリゲートになっています。
よって、自分がデプロイした Enbedding モデルに適合した TokenCounter のデリゲートを渡すようにします。

[Experimental("SKEXP0055")]
public static class TextChunker
{
    /// <summary>
    /// Delegate for counting tokens in a string.
    /// </summary>
    /// <param name="input">The input string to count tokens in.</param>
    /// <returns>The number of tokens in the input string.</returns>
    public delegate int TokenCounter(string input);

 
次の記事
hiro128.hatenablog.jp

C# だけで Azure OpenAI の RAG を試してみたい (1)検討したシステム構成と処理フロー

 

はじめに

最近 Azure OpenAI の RAG について色々調べていますが、サンプルアプリは Python が多いです。
自分は C# が大好きなので、C# でやってみたいな~と思いました。
 
とりあえず、参考になりそうなサンプルを探してたところ以下のようなものを発見しました。
 
github.com
 
上記サンプルは Python で作成されておりとても素晴らしいもので、このサンプルだけで Azure Open AI の Add your data と組み合わせて独自ナレッジの連携を試せるのですが、逆に全自動すぎて何が行われているか理解できませんでした(笑)。そこで、知見を増やすためにこのサンプルをカスタマイズして何か作ってみて理解を深めることにしました。
 

カスタマイズ方針

  • 言語は C# にする。(Python のサンプルが多いので C# のサンプルを作成したい)
  • ソースデータはファイルとしていつでも開けるように Blob にファイルとして保存する。
  • 各々の処理は関数アプリにして自動化する。
  • あまり複雑にしないで、シンプルに必要な要素を学べるサンプルにしたい。
  • 自分にとって役に立ち、運用できるサンプルにしたい。

 

検討したシステム構成と処理フロー

  1. はてなブログの記事を関数アプリで10分ごとにクロールし、Blob に html として保存します。
  2. Blob にアイテムが追加されると、Event Grid の Blob Trigger の関数アプリで AI Search にベクトルを含むドキュメントを登録する。

注意
  • 自分が利用できるようにはてなブログの記事をソースデータにしていますが、ソースデータはその他ブログでも社内のドキュメントでもなんでもかまいません。
  • 個人的な「遊び」で作ったものなのでセキュリティの対応は省いています。業務利用するなら閉域化などの対応をする必要があります。

 

ソースコードと各プロジェクトの解説

上記構成のサンプルアプリを作ったので興味ある方はソースコードを参照ください。
github.com

プロジェクト名 説明
AIClient Open AI, AI Search などのクライアントの Facade。
AzureAISearchIndexInitializer AI Search のインデックスを(既存インデックスが存在すればいったん削除し)作成するコンソールアプリ。
IndexCreator Blob 項目の追加、更新をトリガーにインデックスを作成・更新する関数アプリ。
TextChunkerSample TextChunker の挙動を確認するためのサンプルアプリ。
WebPageCrawler はてなブログの記事をクロールして Blob にアーカイブする関数アプリ。

 
次の記事
hiro128.hatenablog.jp