個人的なメモ

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

C# だけで Azure OpenAI の RAG を試してみたい (7)インデックスへのドキュメント登録

 
前の記事
hiro128.hatenablog.jp
 

ドキュメントの登録

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

Embedding の取得

チャンクしたコンテンツから Embedding (1536次元の数値ベクトル表現)を取得します。

var embedings = await GenerateEmbeddingsAsync(openAIClient, openAIEmbeddingsDeproymentName, chunk);

openAIEmbeddingsDeproymentName は Embedding のデプロイ名です。Azure AI Studio で確認できます。

 

ドキュメントのアップロード

SearchDocument オブジェクトを生成してアップロードすることでインデックスにドキュメントを登録できます。
アップロード済みドキュメントがあれば削除してからアップロードするようにしています。

var searchClient = searchIndexClient.GetSearchClient(aiSerchIndexeName);
await DeleteDocumentByUrlAsync(searchClient, url);
await searchClient.UploadDocumentsAsync(documents);

 

ドキュメントの削除時の注意点

ドキュメントは、key 属性が設定されたフィールドの値指定でしか削除できないので、特定の記事のドキュメントを url フィールドで検索してドキュメントを取得し、 id フィールドの値を取得して、DeleteDocumentsAsyncに指定して削除します。

 

        // url フィールドでインデックスデータを取得し、id フィールドで削除する
        async Task DeleteDocumentByUrlAsync(SearchClient searchClient, string url)
        {
            SearchOptions options = new SearchOptions()
            {
                Filter = $"url eq '{url}'",
                Size = 100
            };

            var response = searchClient.Search<SearchDocument>("*", options);

            foreach (var result in response.Value.GetResults())
            {
                var id = result.Document["id"].ToString() ?? string.Empty;
                await searchClient.DeleteDocumentsAsync("id", new List<string>() { id });
            }
        }

 

ソースコード全体

作成したソースコードの詳細は以下を参照ください。
github.com
 
主要な処理部分は以下の通りです。

            BobyContentChunker chunker = new();
            var chunks = chunker.ChunkText(bodyText);

            List<SearchDocument> documents = new();
            foreach (var (chunk, index) in chunks.Select((chunk, index) => (chunk, index)))
            {
                var id = Guid.NewGuid().ToString();
                var lastUpdatedText = DateTimeOffset.Now.ToString();
                var embedings = await GenerateEmbeddingsAsync(openAIClient, openAIEmbeddingsDeproymentName, chunk);
                
                documents.Add(new SearchDocument
                {
                    ["id"] = id,
                    ["lastUpdated"] = lastUpdatedText,
                    ["content"] = chunk,
                    ["title"] = title,
                    ["filePath"] = url,
                    ["url"] = url,
                    ["metadata"] = "{\"chunk_id\": \"" + id + "\"}",
                    ["contentVector"] = embedings
                });

                //Console.WriteLine($"Uploading documents : {(index + 1).ToString("000000")}/{chunks.Count.ToString("000000")}");
            }

            var searchClient = searchIndexClient.GetSearchClient(aiSerchIndexeName);
            await DeleteDocumentByUrlAsync(searchClient, url);
            await searchClient.UploadDocumentsAsync(documents);
        }

 
ここまでで、「はてなブログの記事を関数アプリで10分ごとにクロールし、Blob に html として保存し、Blob にアイテムが追加されると、Event Grid の Blob Trigger の関数アプリで AI Search にベクトルを含むドキュメントを登録する」アプリが完成しましたので、チャットがこのインデックスの内容を含んだ回答を返してくれるか確認しましょう。

 
次の記事
hiro128.hatenablog.jp

C# だけで Azure OpenAI の RAG を試してみたい (6)AI Search の検索インデックス作成

 
前の記事
hiro128.hatenablog.jp
 

AI Search の検索インデックス作成

クロールしてクレンジングしたはてなブログ記事のドキュメントを登録する AI Search のインデックスを一括で作成するコンソールアプリを準備しました。
github.com
 
なお、AI Search の検索インデックスは RDBMS の世界に例えれば

  • 検索インデックス = RDBMS のテーブル
  • ドキュメント = RDBMS のテーブルにおける行

と理解いただければ、イメージしやすいと思います。
 
検索インデックスのスキーマは以下のように C# のコードで記述できます。
この SDK は頻繁に更新されるので文法が変わっている可能性があります。最新情報を必ずご確認下さい!

            var semanticConfigName = "defaultSemanticConfig";
            var vectorConfigName = "defaultVectorConfigName";
            var algorithmConfigName = "hnsw";

            var vectorSearch = new VectorSearch();
            vectorSearch.Algorithms.Add(new HnswAlgorithmConfiguration(algorithmConfigName));
            vectorSearch.Profiles.Add(new VectorSearchProfile(vectorConfigName, algorithmConfigName));

            var semanticPrioritizedFields = new SemanticPrioritizedFields()
            {
                TitleField = new SemanticField("title"),
            };
            semanticPrioritizedFields.ContentFields.Add(new SemanticField("content"));
            // TODO : Add prioritizedKeywordsFields

            var semanticSearch = new SemanticSearch();
            semanticSearch.Configurations.Add(new SemanticConfiguration(semanticConfigName, semanticPrioritizedFields));

            var defaulIindex = new SearchIndex(indexName)
            {
                VectorSearch = vectorSearch,
                SemanticSearch = semanticSearch,
                Fields =
                {
                    new SearchField("id", SearchFieldDataType.String)
                    {
                        IsKey = true,
                        IsSearchable = true
                    },
                    new SearchField("lastUpdated", SearchFieldDataType.String)
                    {
                        IsSearchable = true,
                        IsFacetable = false,
                    },
                    new SearchField("content", SearchFieldDataType.String)
                    {
                        IsSearchable = true,
                        IsSortable = false,
                        IsFacetable = false,
                        IsFilterable = false,
                        AnalyzerName = LexicalAnalyzerName.JaMicrosoft
                    },
                    new SearchField("title", SearchFieldDataType.String)
                    {
                        IsSearchable = true,
                        IsSortable = false,
                        IsFacetable = false,
                        IsFilterable = false,
                        AnalyzerName = LexicalAnalyzerName.JaMicrosoft
                    },
                    new SearchField("filePath", SearchFieldDataType.String)
                    {
                        IsSearchable = true,
                        IsSortable = false,
                        IsFacetable = false,
                        IsFilterable = false
                    },
                    new SearchField("url", SearchFieldDataType.String)
                    {
                        IsSearchable = true
                    },
                    new SearchField("metadata", SearchFieldDataType.String)
                    {
                        IsSearchable = true
                    },
                    new SearchField("contentVector", SearchFieldDataType.Collection(SearchFieldDataType.Single))
                    {
                        IsSearchable = true,
                        VectorSearchDimensions = 1536,
                        VectorSearchProfileName = vectorConfigName
                    },
                },
            };

 

フィールド

このアプリで作成されるフィールドは以下のようになります。

 
各フィールド定義の詳細は以下の公式ドキュメントを参照ください。
learn.microsoft.com
 

セマンティック構成

このアプリで作成されるセマンティック構成は以下のようになります。

 

ベクタープロファイル

このアプリで作成されるベクタープロファイルは以下のようになります。

  
次の記事
hiro128.hatenablog.jp

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