0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Azure AI Search を用いた RAG用Indexer・Index・Skillset構築

Posted at

Azure AI Search を用いた RAG 向けベクター インデックス構築

背景と全体構成

Azure AI Search(旧称 Azure Cognitive Search)を使用すると、Azure Blob Storage 上のドキュメントや画像からテキストを抽出し、さらにAzure OpenAIの埋め込みモデルでベクトル化した上で検索インデックス化できます。今回のシナリオでは、ストレージ アカウント staifoundryrag 内の test-dev コンテナに格納された Word、Excel、PDF、および画像ファイルを対象に、検索インデクサーで以下の処理を行います:

  • テキスト抽出とAIエンリッチメント: インデクサー実行時にファイルからテキストを抽出し、画像についてはOCR(光学文字認識)や画像分析で説明文を生成します(必要に応じて)。これらの処理はスキルセット(AIスキルのパイプライン)によって行われます。
  • コンテンツ統合: 抽出されたテキストや画像からの情報を統合し、各ドキュメントのコンテンツ全文としてまとめます。例えば、PDFやWord文書内の埋め込み画像から抽出したテキストや説明も、そのドキュメントの内容にマージします。
  • ベクトル生成(Embedding): Azure OpenAIのモデル text-embedding-ada-002 を使用し、統合コンテンツから埋め込みベクトルを生成します。このモデルはOpenAIのAda系列のテキスト埋め込みモデルで、出力は1536次元のベクトルになります(各要素は浮動小数点数)。
  • インデックス構築: 上記のテキストとベクトルをAzure AI Searchのインデックスに格納します。インデックスには、ドキュメントの識別子、ファイル名、全文コンテンツ、ベクトルなどのフィールドを持たせ、ベクトル類似検索ができるよう設定します。
  • 質問応答への活用: 構築したインデックスをRAG(Retrieval-Augmented Generation)の検索基盤として利用します。ユーザの質問に対し、このインデックスから関連コンテンツをベースに応答を生成します。特に画像ファイルについては、抽出したテキストや説明をMarkdown形式でコンテンツに含めておくことで、検索結果から画像に関する質問(画像に写っている内容や画像内のテキスト等)にも回答できるようにします。

以下では、この一連の構成に必要なJSON定義(データ ソースインデックススキルセットインデクサー)を順に示し、各フィールドの意味を初心者向けに詳しく解説します。また、後半ではスキルセットに追加できる処理(OCRや表抽出、カスタム分類など)の具体例と、それを有効にするためにJSONのどの部分に何を追記するかについて説明します。

データ ソースの JSON 定義と解説

まず、Azure AI Searchにデータを供給するデータ ソースを定義します。今回はAzure Blob Storage上のコンテナ (staifoundryrag アカウントの test-dev コンテナ) に格納されたファイルをクロールするため、データ ソースの種類は "azureblob" を使用します。接続にはストレージ アカウントの接続文字列を用います(必要に応じ、Azure AD認証も可能です)。以下にデータ ソースJSONの例を示します。

{
  "name": "testdev-blob-datasource",
  "description": "staifoundryragストレージのtest-devコンテナからドキュメントを取得するデータソース",
  "type": "azureblob",
  "credentials": {
    "connectionString": "<YOUR-AZURE-BLOB-STORAGE-CONNECTION-STRING>"
  },
  "container": {
    "name": "test-dev",
    "query": null
  }
}

データ ソースJSONのフィールド説明:

  • name: データ ソースの名前です。検索サービス内で一意の名前を指定します(例では "testdev-blob-datasource")。後続のインデクサー設定でこの名前を参照します。
  • description: (省略可能)データ ソースの説明です。用途や内容をわかりやすく記述できます。
  • type: データ ソースの種類を指定します。Azure Blob Storageの場合 "azureblob" を指定します。これにより、インデクサーはBLOBストレージからデータを取得するようになります。
  • credentials: ストレージへの認証情報を指定します。ここでは接続文字列を使用しており、"connectionString" にストレージ アカウント staifoundryrag の接続文字列(秘密キーを含む)を記述します(ポータルで自動生成されたキーを貼り付けます)。接続文字列にはストレージのURLとアクセスキーが含まれ、インデクサーがBLOB内容にアクセスする許可を与えます。※Azureポータルからデータ ソースを作成する場合、マネージドIDやキー コンテナー経由の参照も設定可能ですが、ここでは簡便のため直接キーを指定しています。
  • container: クロール対象のコンテナ情報です。
    • name: 対象となるBLOBコンテナ名を指定します。今回は "test-dev" です。
    • query: (省略可能)特定のフォルダやプレフィックスに限定してクロールする場合にクエリ文字列を指定できます。nullもしくは空の場合、コンテナ内のすべてのBLOBが対象となります。例えば、特定の拡張子のファイルだけに限定したい場合は "query": "@container.filterableExtension in '.pdf','.docx'" のように指定できますが、ここでは null として全件を対象にしています。

このデータ ソースをAzureポータル上で作成することで、Azure AI Searchは指定したコンテナ(test-dev)のファイル一覧および各ファイルのメタデータ(名前、パス、サイズ、更新日時など)にアクセスできるようになります。

インデックスの JSON 定義と解説

次に、検索インデックスを定義します。インデックスは検索可能なデータ構造で、ドキュメントごとにフィールド(列に相当)を持ちます。ここではRAG用途およびベクトル検索に必要なフィールドを設計します:

  • ドキュメントID: 各ドキュメント(ファイル)を一意に識別するキー。BLOBストレージ上のパスを用いるか、任意のIDを生成しても構いません。
  • ファイル名: ドキュメントの名前。検索結果としてユーザに提示したり、フィルタリングに使ったりできます。
  • コンテンツ: ドキュメントから抽出したテキスト全文(および画像の説明やOCR結果をマージしたもの)。RAGではこのフィールドの内容が質問応答の元知識となります。全文検索(キーワード検索)も可能にします。
  • ベクトル埋め込み: コンテンツから生成されたベクトル(浮動小数点数の配列)。ベクトル類似検索に使用します。

Azure OpenAIのtext-embedding-ada-002モデルは1536次元のベクトルを出力するため、ベクトルフィールドの次元数(dimensions)は1536とします。またAzure AI Searchでは、ベクトル検索のアルゴリズムとして現在HNSWが提供されていますので、インデックスにHNSW設定を追加します。以下がインデックス定義JSONの例です。

{
  "name": "rag-index",
  "fields": [
    {
      "name": "id",
      "type": "Edm.String",
      "key": true,
      "filterable": false,
      "searchable": false,
      "facetable": false,
      "sortable": false
    },
    {
      "name": "fileName",
      "type": "Edm.String",
      "searchable": true,
      "filterable": true,
      "facetable": false,
      "sortable": false,
      "retrievable": true
    },
    {
      "name": "content",
      "type": "Edm.String",
      "searchable": true,
      "filterable": false,
      "facetable": false,
      "sortable": false,
      "retrievable": true
    },
    {
      "name": "contentVector",
      "type": "Collection(Edm.Single)",
      "searchable": true,
      "filterable": false,
      "facetable": false,
      "sortable": false,
      "retrievable": true,
      "vectorSearchDimensions": 1536,
      "vectorSearchConfiguration": "my-vector-config"
    }
  ],
  "vectorSearch": {
    "algorithmConfigurations": [
      {
        "name": "my-vector-config",
        "kind": "hnsw",
        "distanceMetric": "cosine"
      }
    ]
  }
}

インデックスJSONのフィールド説明:

  • name: インデックスの名前です(例では "rag-index")。検索クエリやインデクサー設定でこの名前を指定してインデックスを参照します。
  • fields: インデックス内のフィールド(列)定義の配列です。それぞれのフィールドに対し、名前・型・オプション属性を指定します。主要なフィールドを順に説明します:
    • id: 各ドキュメントの一意なIDフィールドです。typeは文字列型 (Edm.String) にし、key: true を指定することで主キー(重複不可)になります。ここではBLOBのパスをIDとして利用する想定で id というフィールド名にしました(後述のインデクサーでmetadata_storage_pathをこのidにマッピングします)。searchable: false として全文検索の対象外にし、filterable: falsefacetable: false としてこのフィールドでのフィルタ/集計を行わない設定です。retrievable: true にすると検索結果にこの値を含めて返せます(デフォルトではtrueですが、キー項目なので明示的に取得したい場合以外はどちらでも可です)。
    • fileName: ファイル名を保持するフィールドです。Azure Blobのメタデータmetadata_storage_nameから取得します(インデクサーでマッピング予定)。Edm.String型で、searchable: true とすることでキーワード検索可能にしています。これにより、特定のファイル名や拡張子で検索したり、ユーザが「○○というファイルについて教えて」といった質問をした場合にもヒットするようになります。filterable: true としているため、クエリでファイル名によるフィルタ(例えばfileName eq 'Example.docx'など)も可能です。retrievable: trueなので検索ヒット時にファイル名を結果に含められ、回答の出典表示などに利用できます。
    • content: 抽出された全文コンテンツを格納するフィールドです。ドキュメントのテキスト本文および画像から抽出・生成したテキスト(OCR結果や説明文)が含まれます。typeEdm.Stringで、長文になる可能性が高いため検索可能フィールドとして設定します(searchable: true)。これによりAzure AI Searchの通常のキーワード検索やフィルタでも本文検索ができます。retrievable: trueにしておくことで、後段のRAGシナリオでは検索ヒットしたドキュメントの内容を取り出してAzure OpenAIに渡すことができます。なお、画像のみのファイルなどの場合、このcontentには画像から生成した説明文などが格納されます。filterablefacetableは通常不要なのでfalseにしています。
    • contentVector: コンテンツのベクトル埋め込みを格納するフィールドです。型は Collection(Edm.Single) として浮動小数点数(Edm.Single)のコレクションを指定します。Azure AI Searchではこの型がベクトル用にサポートされており、vectorSearchDimensions プロパティでベクトルの次元数を設定します(Ada-002モデルの場合 1536 次元です)。また、vectorSearchConfiguration には後述の vectorSearch セクションで定義する設定名(ここでは "my-vector-config")を指定します。このフィールドにはインデクサーのスキルセットでOpenAI埋め込みを生成し、その結果(1536個の浮動小数点数の配列)を書き込みます。searchable: trueを指定しているのは「ベクトル検索対象にする」という意味合いで、通常のテキスト検索のようなトークナイズは行われません(ベクトル専用の取り扱いとなります)。このフィールドは検索クエリでvector演算子を使って類似ベクトル検索する際に参照されます。retrievableはシナリオによりますが、trueにしておけば必要に応じてベクトルそのものを取得できます(ただし通常ユーザに見せるものではありません)。
  • vectorSearch: インデックス全体のベクトル検索設定です。algorithmConfigurations配列の中で、ベクトル検索アルゴリズムや距離の種類を定義します。
    • name: アルゴリズム設定の名前です(例えば "my-vector-config")。各ベクトルフィールド(今回だと contentVector)がどの設定を使うかを指定する際にこの名前で紐付けます。
    • kind: アルゴリズムの種類です。現在Azure AI Searchでは "hnsw" (Hierarchical Navigable Small World グラフ) がサポートされているため、それを指定します。
    • distanceMetric: ベクトル間の距離計量の種類です。ここでは "cosine"(コサイン類似度)を選択しています。コサイン類似度はRAGでよく使われる指標で、値が大きいほど類似度が高いと判定されます。Azureのベクトル検索では内部的に1-コサイン類似度を距離として扱います。

上記のインデックス設定により、Azure AI Searchはベクトル検索対応のインデックスを構築します。特に contentVector フィールドに対してHNSWアルゴリズムが適用され、クエリ時にベクトル近傍検索が高速に行えるようになります。なお、このインデックスをAzureポータルで手動作成する場合、ポータルの「インデックス作成 (Import Index)」画面でJSONを貼り付けることができます。

スキルセットの JSON 定義と解説

スキルセットは、インデクサーによるデータ取り込み時に適用されるAI処理のパイプライン定義です。ここでは以下のスキルを組み合わせて、コンテナ内の多様なファイルから必要な情報を引き出し、最終的なコンテンツテキストとベクトルを生成します。

  1. OCRスキル (OcrSkill) – 画像ファイル(またはPDF等に埋め込まれた画像)から文字を読み取ります。例えばスキャンPDFや写真内の文字をテキスト化するのに使用します。
  2. 画像分析スキル (ImageAnalysisSkill) – 写真など画像そのものの内容を分析して説明文(キャプション)やタグを抽出します。画像に文字がない場合でも、この説明文によって画像の内容を表現します。
  3. テキスト結合スキル (MergeSkill) – 抽出された元のテキスト(WordやPDFから取得)と、OCR/画像分析から得られたテキストを結合し、一つの統合コンテンツを生成します。これにより、例えば「PDF内の該当ページ本文 + そこに含まれる画像からOCR取得したテキスト」を一続きのテキストとして扱えるようになります。画像のみのドキュメントであれば、このステップでOCR/説明テキストがそのままコンテンツになります。
  4. カスタム埋め込みスキル (WebApiSkill) – Azure OpenAI にテキストを送り、埋め込みベクトルを取得します。Azure AI SearchにはOpenAIと連携するビルトインスキルはまだ無いため、カスタムWeb APIスキルとしてREST呼び出しを行います。今回はAzure OpenAIのエンドポイントを直接呼び出す想定です(必要に応じてAzure Functions経由でも実装可)。

加えて、スキルセット定義内では**cognitiveServices**の設定を行います。これはOCRや画像分析などAzure AIサービスを呼び出すための認証情報です。Azure Portal上でCognitive Servicesリソースをリンクすることで自動設定も可能ですが、JSONではリソースIDやAPIキーを指定します。

以下にスキルセットのJSON例を示します(Azure OpenAIのエンドポイントやキーはダミーを入れてあります)。

{
  "name": "rag-skillset",
  "description": "Blobドキュメントのテキスト抽出+画像OCR/説明+OpenAI埋め込みを行うスキルセット",
  "skills": [
    {
      "@odata.type": "#Microsoft.Skills.Vision.OcrSkill",
      "context": "/document/normalized_images/*",
      "defaultLanguageCode": "ja",
      "detectOrientation": true,
      "inputs": [
        {
          "name": "image",
          "source": "/document/normalized_images/*"
        }
      ],
      "outputs": [
        {
          "name": "text", 
          "targetName": "ocrText"
        }
      ]
    },
    {
      "@odata.type": "#Microsoft.Skills.Vision.ImageAnalysisSkill",
      "context": "/document/normalized_images/*",
      "visualFeatures": [ "Description" ],
      "defaultLanguageCode": "en",
      "inputs": [
        {
          "name": "image",
          "source": "/document/normalized_images/*"
        }
      ],
      "outputs": [
        {
          "name": "description",
          "targetName": "imageCaption"
        }
      ]
    },
    {
      "@odata.type": "#Microsoft.Skills.Text.MergeSkill",
      "context": "/document",
      "insertPreTag": " ",
      "insertPostTag": " ",
      "inputs": [
        {
          "name": "text",
          "source": "/document/content"
        },
        {
          "name": "itemsToInsert",
          "source": "/document/normalized_images/*/ocrText"
        },
        {
          "name": "itemsToInsert",
          "source": "/document/normalized_images/*/imageCaption"
        },
        {
          "name": "offsets",
          "source": "/document/normalized_images/*/contentOffset"
        }
      ],
      "outputs": [
        {
          "name": "mergedText",
          "targetName": "merged_content"
        }
      ]
    },
    {
      "@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
      "context": "/document/merged_content",
      "uri": "https://<YOUR-AOAI-ENDPOINT>.openai.azure.com/openai/deployments/<EMBEDDING-MODEL-DEPLOYMENT>/embeddings?api-version=2022-12-01",
      "httpMethod": "POST",
      "timeout": "PT30S",
      "batchSize": 1,
      "inputs": [
        {
          "name": "body",
          "source": "/document/merged_content"
        }
      ],
      "httpHeaders": {
        "api-key": "<YOUR-AOAI-API-KEY>",
        "Content-Type": "application/json"
      },
      "outputs": [
        {
          "name": "contentVector",
          "targetName": "contentVector"
        }
      ]
    }
  ],
  "cognitiveServices": {
    "@odata.type": "#Microsoft.Azure.Search.CognitiveServicesByResource",
    "resourceId": "/subscriptions/<SUB-ID>/resourceGroups/<RG-NAME>/providers/Microsoft.CognitiveServices/accounts/<COGNITIVE-SERV-NAME>"
  }
}

スキルセットJSONのフィールド説明:

  • name: スキルセットの名前です(例では "rag-skillset")。インデクサー設定で使用します。
  • description: (省略可能)スキルセットの説明です。処理内容を明示しておくと分かりやすいでしょう。
  • skills: 実行するスキル(AI処理)のリストです。上から順にパイプラインとして適用されます。それぞれのスキル定義について詳しく説明します。
    1. OCRスキル (Microsoft.Skills.Vision.OcrSkill)
      • @odata.type: スキルの種類を示す識別子です。OCRの場合は #Microsoft.Skills.Vision.OcrSkill を指定します。
      • context: スキルの適用対象コンテキストを指定します。"/document/normalized_images/*" は、ドキュメント内のすべての正規化済み画像に対してこのスキルを実行することを意味します。正規化済み画像 (normalized_images) とは、インデクサーの画像処理設定によって生成される、サイズや向きが標準化された画像データです。今回、インデクサー側で imageAction: "generateNormalizedImages" を設定するため、各ドキュメントに埋め込まれた画像や画像ファイル自体が /document/normalized_images/0, /document/normalized_images/1, ... のような形で参照でき、このOCRスキルがそれらに適用されます。
      • defaultLanguageCode: OCRの言語設定です。ここでは "ja"(日本語)を指定しています。Azure AI Vision OCRは多言語対応しており、日本語も含め自動で検出できますが、明示的に指定することで精度向上が期待できます。nullにするとデフォルト(英語)扱いになり、"unk"にすると自動検出モードになります。
      • detectOrientation: 画像の傾き自動検出を行うかどうかです。trueにすると、画像が90度回転しているような場合でもテキスト認識を試みます。
      • inputs: スキルへの入力の指定です。一つの入力として、name: "image"に対しsource: "/document/normalized_images/*"を与えています。つまり、正規化済み画像そのものをOCRエンジンに渡します。インデクサーは画像データを自動的にBase64エンコードしてスキルに渡します。
      • outputs: スキルの出力を指定します。OCRスキルには"text"(画像から抽出したプレーンテキスト)と"layoutText"(テキストのレイアウト情報付き出力)の2種類があります。今回は単純なテキストのみ使うため"text"を指定し、targetName: "ocrText"としています。targetNameはこの出力に付けるラベルで、後続の処理で参照できます。例えばOCRで「STOP」という文字を読んだ場合、その結果文字列がocrTextという名前のフィールドに格納され、/document/normalized_images/*/ocrTextのように参照可能になります(*は画像ごとにそれぞれ存在することを示唆します)。
        補足: サンプルではtargetName"ocrText"と名付けましたが、指定しない場合デフォルトで"text"という名前のノードになります。この出力はコレクション型(複数画像がある場合、各画像のOCR結果を格納するリスト)として扱われます。インデックスにマッピングする際、複数画像のテキストをまとめるため文字列コレクション型にする必要がある点に注意が必要です。
    2. 画像分析スキル (Microsoft.Skills.Vision.ImageAnalysisSkill)
      • @odata.type: 画像分析スキルを示します。#Microsoft.Skills.Vision.ImageAnalysisSkillを指定します。
      • context: OCRと同じく、"/document/normalized_images/*" としています。全ての正規化画像に対し、このスキルを適用します。
      • visualFeatures: 抽出したい視覚特徴を指定します。例えば "Description" を指定すると、画像のキャプション(簡単な説明文)と信頼度、 "Tags"を指定すると画像のタグ(キーワード)リストが得られます。ここでは説明文が欲しいため "Description" のみを指定しました(タグも欲しい場合は ["Description", "Tags"] のように複数指定可能です)。
      • defaultLanguageCode: 生成される説明文の言語を指定できます。OCRと異なり、画像分析のキャプションは指定言語で出力されます。ここでは "en"(英語)を指定しました。Azureの画像分析サービスは日本語キャプション生成も可能ですが精度の点で英語->翻訳の方が良い場合があります。必要に応じ変えてください。
      • inputs: 入力はOCRと同様に、画像データそのものです。name: "image" に対し source: "/document/normalized_images/*" を渡します。
      • outputs: 出力として、"description"(画像の説明文)を受け取ります。targetName: "imageCaption"とし、この名称で後続処理から参照できるようにします。画像分析スキルのDescription出力は通常「画像に写っている物や情景」を一文程度で表したキャプションになります。例えば風景写真なら「A mountain with snow under a blue sky」のような文が imageCaption に入ります。また、このスキルは内部でAzure AI Visionの画像分析APIを使っており、出力はテキストとしてエンリッチド ドキュメントツリーに格納されます。
    3. テキスト結合スキル (Microsoft.Skills.Text.MergeSkill)
      • @odata.type: テキストマージ(結合)スキルを示します。#Microsoft.Skills.Text.MergeSkillを指定します。
      • context: "​/document" としています。これはドキュメント単位で結合処理を行うことを意味します。つまり、一つのドキュメント内の様々な要素(本文テキストや画像テキスト)を集約し、そのドキュメントのコンテンツとしてまとめるため、ルートである/documentコンテキストに出力を作ります。
      • insertPreTag, insertPostTag: 挿入するテキストの前後に付与する文字列を指定します。ここではいずれも半角スペース " " を指定し、単純に空白で区切るようにしています。例えば画像からのテキストを本文に挿入する際、前後にスペースを入れることで前後の単語とくっつかないようにします。Markdown形式で画像を埋め込む場合は、このPre/Postタグを工夫して![]()の記法を挿入することも考えられますが、ここではテキストベースの統合のみ扱います(後述参照)。
      • inputs: MergeSkillに与える入力のリストです。このスキルでは複数の入力源を一つにまとめます。
        • text: 元からあるテキストを指定します。source: "/document/content"とすることで、インデクサーがドキュメントから抽出した本文テキスト(例えばPDFやWordのテキスト部分)が入力されます。これは「画像以外のテキスト部分」と考えてください。
        • itemsToInsert (1つ目): 挿入すべき項目として、OCR結果を指定します。source: "/document/normalized_images/*/ocrText"とすることで、すべての画像についてOCRで得られたテキスト(先ほどのocrText出力)が取得されます。これらはリスト(コレクション)として扱われ、ドキュメント内で適切な箇所(後述のoffsets位置)に挿入されます。
        • itemsToInsert (2つ目): 続けて、画像キャプションを挿入項目として指定します。source: "/document/normalized_images/*/imageCaption"とすることで、各画像について生成した説明文を取得します。結果として、OCRテキストとキャプションの両方を挿入対象としています。注意: MergeSkillのitemsToInsertは複数指定できますが、それぞれoffsetsとの対応関係に注意が必要です。同じ順序でマージされる想定です。ここでは簡略化のためOCRテキストとキャプションをまとめて扱っていますが、実運用では画像内にテキストがある場合はOCRテキストのみ、無い場合はキャプションを使う、といった条件分岐をカスタムスキルで制御することも考えられます。
        • offsets: どの位置に挿入するかを示すオフセットです。source: "/document/normalized_images/*/contentOffset"を指定しています。contentOffsetは各画像が元ドキュメント内のどの位置(文字オフセット位置)に存在したかを表すメタデータで、Azure BlobインデクサーがimageAction有効時に提供します。このoffsetを使うことで、画像由来のテキストを元のテキスト中の正しい位置に挿入することができます。例えばPDF内で段落の途中に画像がある場合、その画像のOCR結果や説明を段落中の適切な位置に挿入できます。
      • outputs: マージ後の出力を指定します。"mergedText"という出力を作り、targetName: "merged_content"としています。つまりマージ後の全文を merged_content というフィールド名でエンリッチド ドキュメントツリーに保持します。このmerged_contentには「元の本文テキスト + 各画像からのテキスト/説明」が統合された一つのテキストが入ります。もしドキュメントに画像が無ければ、merged_contentは元のcontentと同じ内容になります。後続のベクトル生成やインデックスへの出力マッピングはこのmerged_contentを使うようにします。なお、ここではMarkdown形式へのフォーマット変換は行っておらず、生のテキストを結合しています(画像に対応するテキストがその場に挿入された形)。
    4. カスタム埋め込みスキル (Microsoft.Skills.Custom.WebApiSkill)
      • @odata.type: カスタムWeb APIスキルを示します。#Microsoft.Skills.Custom.WebApiSkillを指定します。このスキルではAzure OpenAIの埋め込みAPIを呼び出します。
      • context: "​/document/merged_content" と指定しています。これは、先ほどのMergeSkillで得られたドキュメント単位の統合テキストに対してこのスキルを適用することを意味します。つまり各ドキュメントのmerged_contentが埋め込みAPIの入力となります。
      • uri: 呼び出す外部APIのエンドポイントURLです。ここにはAzure OpenAIリソースのエンドポイントを指定します。一般にAzure OpenAIでは、<リソース名>.openai.azure.com以下にREST APIエンドポイントがあり、モデルのデプロイ毎にパスが決まっています。書式は
        https://<あなたのOpenAIリソース名>.openai.azure.com/openai/deployments/<デプロイ名>/embeddings?api-version=<バージョン>
        
        となります。例えばOpenAIリソース名がmy-openairgで、text-embedding-ada-002モデルをada-embedという名前でデプロイしている場合、<YOUR-AOAI-ENDPOINT>my-openairgを、<EMBEDDING-MODEL-DEPLOYMENT>ada-embedを入れ、APIバージョンは執筆時点で利用可能なもの(例えば2022-12-01)を指定します。上記JSON例ではダミー値を入れていますが、利用者は自分のAzure OpenAIリソースに合わせて書き換えてください
      • httpMethod: HTTPメソッドです。OpenAIの埋め込みAPIはPOSTリクエストでテキストを送信するため "POST" と指定しています。
      • timeout: API呼び出しのタイムアウト時間です。"PT30S"は30秒を意味します。埋め込み生成は高速ですが、ネットワーク遅延等を考慮し適度なタイムアウトを設定します。
      • batchSize: インデクサーが並列呼び出しする際のバッチサイズです。1に設定することで、1ドキュメントずつこのAPIを呼び出すようにします。こうすることで、OpenAI APIには1件のテキストを送る単純なリクエストとなります。複数まとめて送信も可能ですが(OpenAIは一度に複数テキストのembedding取得をサポートしています)、実装の容易さのためバッチサイズ1にしています。
      • inputs: 外部APIに渡す入力です。OpenAI埋め込みAPIはJSONボディでテキストを渡すため、name: "body"を指定し、source: "/document/merged_content"を与えています。WebApiSkillの特殊な点として、Cognitive Searchはデフォルトでスキルに"values"形式のラップしたJSONを送ります。しかし、inputs"body"を指定すると、この値をそのままHTTPリクエストボディとして送信してくれます。よって、merged_content(統合テキスト文字列)が丸ごとOpenAI APIにリクエストされることになります。ただし、OpenAIのエンコード制限(Adaモデルは約8191トークンまで)を超える長大なテキストは、前段でSplitSkill等により短くする必要があります(今回は考慮しません)。
      • httpHeaders: 外部APIに付与するHTTPヘッダーをキー・バリュー形式で指定します。Azure OpenAIの場合、認証に APIキー を用いますので、"api-key": "<YOUR-AOAI-API-KEY>" を指定します。また、送信コンテンツがJSONなので "Content-Type": "application/json" も指定します。APIキーはAzure OpenAIリソースのキー(ポータルの「キーとエンドポイント」に表示)を使用します。セキュリティ注意: JSONにAPIキーを直接記述していますが、本番環境ではキーボルト参照などで秘匿すべきです。インデクサー設定JSONでは直接記載するしかないため、扱いに注意してください。
      • outputs: 外部APIからの出力を定義します。"contentVector"という名前で受け取り、targetName: "contentVector"としています。ここでは、OpenAIの埋め込みAPIレスポンスからベクトル値を取り出してcontentVectorノードに格納することを意図しています。Azure OpenAIのEmbedding APIは応答JSONに埋め込みベクトル(1536要素の配列)を含むdataフィールドを返しますが、WebApiSkillでは外部サービスからの応答をそのままではなく、一旦Cognitive Searchがラップして扱います。そのため、実際にはOpenAIレスポンスを加工してcontentVector配列のみ返すAzure Functionを中継する方法もあります。しかし、ここではシンプルに説明するため、OpenAIから直接embedding配列を受け取れるものと仮定しています。必要に応じてAzure Functionsで以下のようなラッパーを作成できます: リクエストのvaluesからテキストを取り出しOpenAIに投げ、返ってきたembedding配列をvalues形式で返す(custom skillの入出力契約に従う)。そうすればこのoutputs設定でembedding配列を受け取れます。
        • (補足): 上級者向けですが、OpenAI埋め込みAPIのJSONレスポンスは例えば以下のようになります:
          {
            "data": [ { "index": 0, "embedding": [0.123, 0.456, ...] } ],
            "usage": {...},
            "model": "text-embedding-ada-002"
          }
          
          これをカスタムスキルで受け取った場合、outputsname: "contentVector"としておけば、自動的にdata[0].embedding配列をcontentVectorノードに格納してくれる想定です(もしくはFunction側で{"contentVector": [ ... ]}だけ返す実装にします)。いずれにせよ、最終的にこのcontentVectorノードがインデックスのベクトルフィールドにマップされるようにします。
  • cognitiveServices: スキルセット全体で使用する認知サービスの設定です。これはOCRや画像分析などAzure AIサービスを呼び出す際の認証情報を示します。2通り指定方法があり、1つはAzure Cognitive ServicesリソースのリソースIDを指定する方法、もう1つはキーを直接指定する方法です。上記例では@odata.type: "#Microsoft.Azure.Search.CognitiveServicesByResource"を用い、resourceIdプロパティにAzure Cognitive ServicesリソースのARMリソースIDを設定しています。例えば、事前にMulti-serviceのCognitive Servicesリソース(あるいはAzure AI ServicesのComputer Visionリソースなど)を作成しておき、そのリソースIDをここに書きます。こうすることで、Azure AI Searchはそこに紐づくAPIキーを使ってOCRや画像分析を実行します。もしリソースIDがない場合、
    "cognitiveServices": {
        "@odata.type": "#Microsoft.Azure.Search.CognitiveServicesByKey",
        "description": "my-cs-key",
        "key": "<COGNITIVE-SERVICES-API-KEY>"
    }
    
    のようにAPIキーを直接指定することも可能です。Portalでリンクする場合はいずれかを選択するUIになっています。重要: この設定はOCRやImageAnalysisなど組み込みスキルの呼び出しに必要です。無料利用枠(1日20ドキュメント以内)を超えてインデクサーを動かす場合、必ず適切なCognitive Servicesリソースまたはキーを指定してください。Azure OpenAIの呼び出し(WebApiSkill)はこの設定とは無関係で、上記httpHeadersで渡したAPIキーが使われます。

以上がスキルセットの定義となります。このスキルセットをポータルで作成する際は、「スキルセットのインポート」画面等でJSONを貼り付けて登録できます(Cognitive Servicesのリンクはポータル上で別途指定する形になるかもしれません)。定義後、rag-skillsetという名前で検索サービス上に保存されます。

インデクサーの JSON 定義と解説

最後に、インデクサーを定義します。インデクサーはデータ ソースからドキュメントを取得し、必要に応じスキルセットでエンリッチ処理を行い、結果をインデックスに投入する処理(パイプライン全体)を担うコンポーネントです。インデクサーのJSONでは、データ ソース・スキルセット・インデックスを紐づけ、フィールドマッピング(入力からインデックス項目への対応付け)や実行スケジュールなどを指定します。

今回のインデクサー設定のポイント:

  • 前述のデータ ソース名、インデックス名、スキルセット名を関連付ける。
  • BLOBメタデータ(ファイルパスや名前)をインデックスのidfileNameにマッピングする。
  • スキルセット出力(merged_content や contentVector)をインデックスの対応フィールドにマッピングする。
  • 画像を処理するため、imageActionを有効にし、dataToExtractparsingModeを適切に設定する(これにより normalized_images フィールドが生成されOCR等が機能します)。
  • 途中エラーが発生してもインデクサー全体が停止しないよう、許容設定を行う。

以下にインデクサーJSONの例を示します。

{
  "name": "rag-indexer",
  "dataSourceName": "testdev-blob-datasource",
  "targetIndexName": "rag-index",
  "skillsetName": "rag-skillset",
  "fieldMappings": [
    {
      "sourceFieldName": "metadata_storage_path",
      "targetFieldName": "id"
    },
    {
      "sourceFieldName": "metadata_storage_name",
      "targetFieldName": "fileName"
    }
  ],
  "outputFieldMappings": [
    {
      "sourceFieldName": "/document/merged_content",
      "targetFieldName": "content"
    },
    {
      "sourceFieldName": "/document/contentVector",
      "targetFieldName": "contentVector"
    }
  ],
  "parameters": {
    "batchSize": 10,
    "maxFailedItems": -1,
    "maxFailedItemsPerBatch": -1,
    "configuration": {
      "dataToExtract": "contentAndMetadata",
      "parsingMode": "default",
      "imageAction": "generateNormalizedImages"
    }
  }
}

インデクサーJSONのフィールド説明:

  • name: インデクサーの名前です(例では "rag-indexer")。検索サービス内でインデクサーを一意に識別します。後でこのインデクサーを実行(Run)する際にもこの名前を指定します。
  • dataSourceName: どのデータ ソースからデータを取得するかを指定します。ここでは先ほど作成したデータ ソース "testdev-blob-datasource" を指定しています。この名前により、インデクサーは staifoundryragアカウントのtest-devコンテナ内のBLOB一覧を取得します。
  • targetIndexName: インデクサーの出力先となるインデックス名を指定します。ここでは "rag-index"(上で定義したインデックス)を指定しています。インデクサーは抽出・加工したデータをこのインデックスのドキュメントとして投入します。
  • skillsetName: 利用するスキルセット名を指定します。ここでは "rag-skillset"(上で定義したスキルセット)です。この指定により、インデクサーは各ドキュメントに対しスキルセット内のOCRやMerge等を順次適用します。もしAIエンリッチを行わない場合、この項目は省略(もしくはnull)にできますが、今回は必須です。
  • fieldMappings: データソースからインデックスのフィールドへの直接マッピングを定義します。スキルを介さず、そのままコピーしたい項目に対して使います。ここでは BLOBのメタデータ項目をインデックスのフィールドに写しています:
    • metadata_storage_path -> id: Blobのフルパス(URL)が格納されているシステムメタデータmetadata_storage_pathを、インデックスのidフィールドに割り当てます。これにより、自動的に各ファイルのパス文字列がidとしてインデックスに登録され、ユニークキーとなります(BLOBのパスは一意なのでキーに適しています)。例えば https://staifoundryrag.blob.core.windows.net/test-dev/Report1.pdf のような値です。
    • metadata_storage_name -> fileName: Blobのファイル名(例: Report1.pdf)が入ったmetadata_storage_nameを、インデックスのfileNameフィールドに割り当てます。これにより、インデックス上でファイル名情報が保持され、検索や表示に利用できます。
    • (必要に応じて他のメタ項目もマッピング可能です。例えばmetadata_storage_size(サイズ), metadata_storage_last_modified(最終更新日時)などをインデックスに保持したい場合は同様に追加できます)。
  • outputFieldMappings: スキルセットからの出力をインデックスのフィールドにマッピングする定義です。スキルセットで生成されたエンリッチド ドキュメント内のフィールド(パス表記で指定)を、インデックスのフィールドに対応付けます。
    • /document/merged_content -> content: スキルセットの最終出力であるmerged_contentをインデックスのcontentフィールドにマップします。これにより、OCRや画像キャプションも含めた統合テキスト全文がインデックスのcontentに格納されます。検索やRAGで使用する主テキストとなります。
    • /document/contentVector -> contentVector: スキルセット内のWebApiSkill出力であるベクトル(contentVectorノード)を、インデックスのcontentVectorフィールドにマップします。1536次元の浮動小数配列データがそのままインデックスに格納されます。このフィールドは上でvectorSearchDimensions:1536として設定済みなので、そのサイズの配列以外は受け付けないことに注意してください。うまくマッピングされれば各ドキュメントにembeddingが付与され、ベクトル検索に使用可能となります。
  • parameters: インデクサーの動作パラメータをまとめて指定します。
    • batchSize: 一度に処理するアイテム数です。例えば10とすれば、一度に10ファイルずつ取得して処理します。大きすぎるとメモリに載り切らない場合がありますが、小さすぎるとスループットが下がります。ここでは例として10にしています。
    • maxFailedItems, maxFailedItemsPerBatch: インデクサー実行時に許容するエラー数の設定です。-1を指定すると無制限(全件失敗でも最後まで実行を継続)を意味します。ここでは両方-1に設定し、処理中にエラーになるドキュメントがあってもインデクサー全体が停止しないようにしています。例えば一部のファイルでOCRに失敗したり、OpenAI APIでエラーが発生しても、他のファイルの処理は続行されます。実運用では失敗アイテム数をログから確認しつつ再インデックスするなどの対処が必要です。
    • configuration: インデクサー固有の詳細設定です。辞書形式で以下のキーを指定しています:
      • dataToExtract: 抽出するデータの種別です。"contentAndMetadata"を指定することで、ファイルの本文コンテンツとメタデータの両方を抽出対象にします。本文コンテンツとはPDFやOffice文書内のテキスト、メタデータとはファイル名やサイズなどです。OCRやテキスト抽出には本文が必要なので、contentを含めるこの設定が必須です。
      • parsingMode: ファイルのパース方法を指定します。"default"は標準的な解析モードで、1つのBLOB(ファイル)につき1つのドキュメントを生成します。これを"default"以外にすると、例えば大きなファイルを分割して別ドキュメントにすることもできますが、画像エンリッチメントを行う場合は1対1の対応(default)が必要です。したがって必ず"default"とします。
      • imageAction: 画像処理のアクション設定です。"generateNormalizedImages"を指定すると、各ドキュメントから画像を抽出し、正規化処理を行ってスキルセットに渡します。これによって、先述の/document/normalized_imagesノードがエンリッチメントツリーに生成され、OCRスキルや画像分析スキルが参照できるようになります。例えばPDFやWordに埋め込まれた画像は独立した画像データに分離され、またJPEG/PNGファイルそのものもnormalized_images配列に取り込まれます。imageActionnone(もしくは指定省略)にすると画像は無視され、OCRスキルも入力を得られません。generateNormalizedImagePerPageというオプションもありますが(PDFの場合に各ページを画像化するモード)、通常はgenerateNormalizedImagesで十分です。
        ※なお、normalizedImageMaxWidthnormalizedImageMaxHeightで正規化画像の最大サイズ(ピクセル)も指定可能です。デフォルト2000pxですが、高解像度OCRが必要でCognitive Servicesの上限(英語で10000px)まで上げることもできます。

以上のインデクサー設定をAzureポータルで作成・実行することで、Blobストレージ内のファイル群が順次取り込まれ、定義したインデックス (rag-index) にドキュメントとして登録されます。インデクサーはスケジュール実行も可能ですが、ここでは手動またはオンデマンドで実行する前提です(Portal上で「今すぐ実行」を押すか、REST APIで/runエンドポイントを呼ぶ)。

スキルセットへの追加処理の例 (OCR・表抽出・カスタム分類 など)

上述の設定では代表的なスキルとしてOCRや画像キャプション抽出、OpenAI埋め込みを組み込みました。さらに要件に応じてスキルセットに追加できる処理や、その有効化方法をいくつか紹介します。これらを組み込む場合は、スキルセットのskills配列に新たなスキル定義を追加し、必要ならインデックスにフィールドを追加して、インデクサーの出力マッピングも追記することになります。それぞれ具体例を挙げます。

  • 画像からのOCR (Optical Character Recognition) – 今回の例では既にOCRスキルを追加済みですが、例えば省略していた場合を考えます。OCRを有効にするには:

    • スキルセットのskills配列に、先述の OcrSkill 定義を追加します。context"/document/normalized_images/*"inputsimageソースを指定し、outputstext(またはlayoutText)を出力します。defaultLanguageCodeも適宜設定します。
    • インデクサーのparameters.configuration.imageAction"generateNormalizedImages"に設定します(OCRスキルが画像にアクセスできるようにするため)。
    • 抽出結果の扱い: 単に全文検索させるだけなら、OCR結果をMergeSkillで本文に含めるようにします(今回の設定どおり)。個別に保持したい場合、インデックスに例えばocrTextフィールド(Collection(Edm.String)型)を追加し、インデクサーのoutputFieldMappings/document/normalized_images/*/textをそのフィールドにマップします。そうすると全画像のOCR文字列一覧をそのまま保持できますが、通常はMergeしてしまう方が扱いやすいでしょう。
    • Azure AI Searchでは、OCRスキルはAzure AI VisionのRead APIを使っており、日本語や英語を含む多数の言語に対応しています。組み込みスキルの実行にはCognitive Servicesリソースが必要な点も注意してください。
  • 画像の説明文・タグ抽出 (Image Captioning & Tagging) – 画像分析スキルを使うと、OCRでは得られない画像の内容(風景や物体など)をテキストで表現できます。有効化するには:

    • スキルセットに ImageAnalysisSkill を追加します。visualFeatures"Description"(説明文)や"Tags"(タグ群)を指定します。出力として"description"(または"captions"という名前で出てくる場合もあります)や"tags"を受け取れます。
    • インデックスに対応するフィールドを追加します(例えば説明文用にimageCaption(Edm.String)フィールド、タグ用にimageTags(Collection(Edm.String))フィールドなど)。
    • インデクサーのoutputFieldMappingsで、/document/normalized_images/*/description -> imageCaption/document/normalized_images/*/tags -> imageTags のようにマッピングします。
    • あるいは今回のようにMergeSkillで本文に組み込む場合は、説明文をMergeSkillのitemsToInsertに含めています。Markdown形式で画像を埋め込む場合、説明文は画像の代替テキスト(altテキスト)として利用できます。
    • Markdown形式で画像を扱う: 画像そのものを回答やコンテンツに埋め込みたい場合、インデックスのテキストに画像参照を記述する方法があります。例えば、各画像について
      ![<キャプション>](<画像のURL>)
      
      というMarkdown文字列を生成し、本文に入れる手法です。このためにはカスタムスキルでmetadata_storage_path(画像URL)とdescription(キャプション)を組み合わせた文字列を出力し、それをMergeSkillで差し込む、という実装になります。簡単には、ShaperSkillやカスタムWebApiSkillでJSONを組み立てることが考えられます。今回の構成では扱いませんでしたが、実現は可能です。なお、Markdown形式で画像を表示するには、そのURLにアクセス権が必要です。コンテナを公開するか、SASトークン付きURLを用いる必要があります。
  • 表の抽出 (テーブル検出・抽出) – PDFやExcel等に含まれる表形式データを構造的に取り出したい場合、Azure Cognitive Search単独では難しいですが、Azure Form Recognizer(Document Intelligence) のレイアウトモデルを組み合わせることで実現できます。方法の一例:

    • Azure Form RecognizerのLayout APIにドキュメントを送り、テーブルを検出・抽出します(テーブルセルのテキスト、行列インデックスなどがJSONで返ります)。Form Recognizer v4では結果をMarkdown形式で返すオプションもあり、抽出結果をMarkdownの表形式として取得することもできます。
    • これをAzure AI Searchに組み込むには、カスタム WebApiSkill を用いて、Form RecognizerのREST APIを呼び出します。たとえば、context: "/document"に対してWebApiSkillを配置し、inputs/document/data(バイナリデータ)や/document/content(テキスト。テキストからは表構造復元困難なのでバイナリ推奨)を渡し、Form Recognizerのエンドポイントをuriに指定します。認証にはFRのAPIキーをhttpHeadersに含めます。スキルコード側でFRからのレスポンスを受け取り、JSON文字列やMarkdown文字列としてoutputsに返します。
    • インデックスに、その出力を格納するフィールドを用意します(例えば tables フィールド)。形式は単純に全文に含めるならEdm.Stringでもよいし、JSONのまま保持したければRetrievableな文字列として格納します。
    • インデクサーのoutputFieldMappingsでカスタムスキルの出力(/document/yourTableOutputなど)をそのフィールドにマップします。
    • こうすることで、検索時に表データもテキストとして検索可能になりますし、回答生成時にフォーマット済み表(Markdownならそのまま表レンダリング可)を含めることもできます。特にMarkdown出力を活用すれば、Excelの表などをかなり見やすく提供できるでしょう。
    • 注意点: FRにファイルを送る場合、上記のようにカスタムスキルから直接Blobストレージを読ませる(SAS URLを使う)か、インデクサーから渡せるBase64データ(例えば画像ではnormalized_images/*/dataが使えます。PDFバイナリ全体はデフォルトではインデクサーから直接取れないため、SAS URL入力が現実的です)を用いる必要があります。
    • 代替: 単に表内テキストも検索したいだけなら、実はインデクサーの標準テキスト抽出で表の内容もスペース区切りでcontentに含まれます。構造は失われますが、全文検索の点では自動で対象になっています。構造付きで使いたい場合のみFR連携が必要です。
  • カスタム分類 (Custom Classification) – ドキュメント内容に基づいてタグ付けやカテゴリー分類をしたい場合、独自のMLモデルやルールを適用できます。Azure Cognitive Searchには汎用のAzure ML SkillCustom WebApiSkillが用意されているので、それらで実装可能です。例として、ドキュメントを「技術資料」「財務情報」「人事情報」などカテゴリに分類するケースを考えます:

    • あなたがすでにテキスト分類モデルを持っている場合(例えばAzure MLで学習済みのエンドポイント、またはAzure OpenAIのGPTを使ってプロンプトでカテゴリ分類する方法など)、それを呼び出すCustom WebApiSkillをスキルセットに追加します。context/document/merged_content(もしくは単に/document/contentでも)にして、ドキュメント全体のテキストを入力とします。uriにはAzure MLエンドポイントやFunctionsのURLを指定し、APIキー等必要ならhttpHeadersに含めます。期待する出力はカテゴリ名ですから、例えばoutputsname: "category", targetName: "category"のように設定します。
    • インデックスにcategoryフィールド(Edm.String)を追加し、filterable: truefacetable: trueに設定しておくと、あとでカテゴリでフィルタリング検索したり集計したりできます。
    • インデクサーのoutputFieldMappingsにて、/document/category(スキル出力)をインデックスのcategoryフィールドにマップします。
    • こうすることで、各ドキュメントに分類結果が書き込まれます。たとえば"技術資料"というカテゴリが付与されれば、そのキーワードでも検索ヒットしますし、ユーザが「技術資料だけ」と絞り込むことも可能になります。
    • 別のアプローチとして、Azure OpenAIのGPT-4/3.5に「本文を読んでカテゴリを返す」ようなPromptを送り、結果を返すFunctionを作ることもできます。その場合も手順は同様で、WebApiSkillでそのFunctionを呼び出します。実行コストと応答時間には注意してください。
  • その他の組み込みスキル: Azure AI Searchには他にも、たとえば 言語検出スキル (LanguageDetectionSkill)キーフレーズ抽出スキル (KeyPhraseExtractionSkill)人名・地名などエンティティ抽出スキル (EntityRecognitionSkill)感情分析スキル (SentimentSkill) 等があります。これらもスキルセットのskills配列に追加し、出力をインデックスにマップすることで利用可能です。例えば言語検出を使えば各ドキュメントの主要言語をISOコードで記録でき、多言語対応のQAで役立つかもしれません。またキーフレーズ抽出を使えば文書の重要語をメタデータとして蓄積でき、検索精度向上に寄与します。SplitSkillを使えば長い文書をページ単位や段落単位に分割できますが、画像との対応が複雑になるため注意が必要です(画像処理時は基本1ファイル=1ドキュメントで扱うのが推奨です)。

以上、追加可能なスキル処理の例を挙げました。新たなスキルを組み込む際は、

  1. スキルセットJSONに該当スキルの定義を追記し(skills配列にオブジェクトを追加)、必要ならcognitiveServicesの設定も対応するサービスキーを含めます(例えばForm Recognizer呼び出しならそのキーをheadersに入れるなど)。
  2. インデックスJSONに新たな出力を受け取るフィールドを追加します(テキストならEdm.String、複数値ならCollection(Edm.String)など適切な型)。
  3. インデクサーJSONoutputFieldMappingsにそのスキル出力ノードをインデックスフィールドへマップするエントリを追加します。場合によってはfieldMappingsも増やします(データソースから直接マップする場合など)。
  4. 既存の設定との兼ね合い(例えばMergeSkillで既に統合しているが別フィールドでも保持するのか等)を考慮し、全体を整合させます。

最後に、Azureポータル上ではデータ ソース→インデックス→スキルセット→インデクサーの順にリソースを作成し、インデクサーを実行することで一連の処理が動作します。上記JSONはいずれもAzure Cognitive SearchサービスのREST APIボディと同等ですので、ポータルの「Import JSON」機能に貼り付けて作成できます。これらを正しく設定すれば、Blob内のWord/PDF/画像からテキストとベクトルが抽出・索引化され、RAGシステムがそれらを検索・参照して回答を生成できるようになります。各設定項目の意味を理解しながら調整することで、ニーズに合わせた検索基盤を構築できるでしょう。

参考文献・ソース:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?