Azure AI Search のスキルセットで PDF ファイルを読み込んでチャンク分割してベクトル化する

2023/11に追加された以下の機能で Azure AI Search のスキルセットにてチャンク分割とベクトル化ができるようになりました (ただ、2024/02/09時点ではパブリックプレビュー段階なので注意)
 - [Azure AI Search でのインデックス プロジェクション](https://learn.microsoft.com/ja-jp/azure/search/index-projections-concept-intro?tabs=kstore-rest)
 - [Azure OpenAI Embedding スキル](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-azure-openai-embedding)
 - [テキスト分割コグニティブ スキル](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-textsplit)

よくある Azure AI Search の利用ストーリーである、Azure Blob Storage に格納されている PDF ファイルを読み込んで、チャンク分割して Azure OpenAI Service の Embeddings API でベクトル化して Azure AI Search のインデックスに格納する、を実現するためのインデックス、インデクサー、データソース、スキルセットの定義例(JSON)をメモしておきます。

> 以下の定義で使っているインデックスプロジェクション等の機能は ```2023-10-01-Preview``` の API を使用しないと使えないのに注意

## インデックスの定義例
```json
{
    "name": "",
    "fields": [
        {
            "name": "id",
            "type": "Edm.String",
            "key": true,
            "searchable": true,
            "filterable": false,
            "sortable": false,
            "facetable": false,
            "analyzer": "keyword"
        },
        {
            "name": "parentKey",
            "type": "Edm.String",
            "searchable": false,
            "filterable": true,
            "sortable": false,
            "facetable": false
        },
        {
            "name": "metadata_storage_path",
            "type": "Edm.String",
            "searchable": false,
            "filterable": true,
            "sortable": false,
            "facetable": false
        },
        {
            "name": "metadata_storage_name",
            "type": "Edm.String",
            "searchable": false,
            "filterable": true,
            "sortable": false,
            "facetable": false
        },
        {
            "name": "page",
            "type": "Edm.Int32",
            "searchable": false,
            "filterable": false,
            "sortable": true,
            "facetable": false
        },
        {
            "name": "text",
            "type": "Edm.String",
            "searchable": true,
            "filterable": false,
            "sortable": false,
            "facetable": false,
            "analyzer": "ja.microsoft"
        },
        {
            "name": "textVector",
            "type": "Collection(Edm.Single)",
            "searchable": true,
            "dimensions": 3072,
            "vectorSearchProfile": "vectorProfile"
        }
    ],
    "vectorSearch": {
        "algorithms": [
            {
                "name": "hnsw",
                "kind": "hnsw",
                "hnswParameters": {
                    "m": 4,
                    "efConstruction": 400,
                    "efSearch": 500,
                    "metric": "cosine"
                }
            }
        ],
        "vectorizers": [
            {
                "name": "azureOpenAI",
                "kind": "azureOpenAI",
                "azureOpenAIParameters": {
                    "resourceUri": "",
                    "deploymentId": "text-embedding-3-large",
                    "apiKey": ""
                }
            }
        ],
        "profiles": [
            {
                "name": "vectorProfile",
                "algorithm": "hnsw",
                "vectorizer": "azureOpenAI"
            }
        ]
    },
    "similarity": {
        "@odata.type": "#Microsoft.Azure.Search.BM25Similarity",
        "k1": 1.2,
        "b": 0.75
    }
}
```

ベクトル検索可能とするために、ベクトルを格納するためのフィールドである```textVector```フィールドを用意しておく。type は Collection(Edm.Single) で dimensions にはベクトルの次元数を指定する(Azure OpenAI Service の text-embedding-3-large の次元数は 3072)  

加えて、テキストでやってきた検索クエリをベクトル化して検索できるようにするために```vectorSearch```を定義しておく。```vectorSearch```の中には```algorithms```と```vectorizers```、```profiles```を定義する。```algorithms```ではベクトル検索に使用するアルゴリズムやそのパラメータを定義する([参考](https://learn.microsoft.com/ja-jp/azure/search/vector-search-ranking))。```vectorizers```ではベクトル化に使用するモデルを指定する。```profiles```はアルゴリズムとベクトライザーの組み合わせで、ここで定義したプロファイルを、ベクトルフィールド(ここでの```textVector```)での```vectorSearchProfile```で指定する。  

インデックスプロジェクションに対応するために、key フィールド(今回だと```id```)は```analyzer```として```keyword```を使用するように設定する必要があり、また親検索ドキュメントのキーを格納するためのフィールド(今回だと```parentKey```)を用意しておく必要がある(どちらも名前は任意)。

```vectorizers```では Azure OpenAI Service のモデルだけでなく、他のモデルも指定することができるが、それは Web API 経由でアクセスできる必要があるので、Azure AI Search 自体にモデルを配置してベクトル化処理をして、みたいなことではない。また、ここでのベクトル化の設定は、テキストでやってきた検索クエリーをベクトル化してベクトル検索できるようにするためであって、格納されたテキストをベクトル化するのはスキルセットの役目である。

> 参考：[Indexes - Create - REST API (Azure Search Service) | Microsoft Learn](https://learn.microsoft.com/ja-jp/rest/api/searchservice/indexes/create?view=rest-searchservice-2023-10-01-preview)

## インデクサーの定義例
```json
{
    "name": "",
    "dataSourceName": "",
    "skillsetName": "",
    "targetIndexName": "",
    "parameters": {
        "configuration": {
            "dataToExtract": "contentAndMetadata",
            "parsingMode": "default",
            "imageAction": "generateNormalizedImagePerPage",
            "allowSkillsetToReadFileData": true
        }
    }
}
```

特筆すべき点はないです。よくあるPDFファイルを処理するためのインデクサーの定義って感じ。

> 参考：[Indexers - Create - REST API (Azure Search Service) | Microsoft Learn](https://learn.microsoft.com/ja-jp/rest/api/searchservice/indexers/create?view=rest-searchservice-2023-10-01-preview)


## データソースの定義例
```json
{
    "name": "",
    "type": "azureblob",
    "name": "",
    "credentials": {
        "connectionString": ""
    },
    "container": {
        "name": ""
    },
    "dataDeletionDetectionPolicy": {
        "@odata.type": "#Microsoft.Azure.Search.SoftDeleteColumnDeletionDetectionPolicy",
        "softDeleteColumnName": "IsDeleted",
        "softDeleteMarkerValue": "true"
    }
}
```

これも特筆すべき点はないです。```connectionString```には、キー認証を使うのであれば使用する Azure Blob Storage の接続文字列を、マネージドID認証を使うのであれば Storage のリソースIDを指定する。マネージドID認証を行う場合は、使用する Azure AI Search アカウントのマネージドID設定をオンにして、そのマネージドIDに使用する Azure Blob Storage への```ストレージ BLOB データ閲覧者```や```ストレージ BLOB データ共同作成者```を付けておくこと。一応、データ削除検出を行うようにしている。

## スキルセットの定義例
```json
{
    "name": "",
    "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": "text"
                },
                {
                    "name": "layoutText",
                    "targetName": "layoutText"
                }
            ]
        },
        {
            "@odata.type": "#Microsoft.Skills.Text.SplitSkill",
            "context": "/document/normalized_images/*",
            "textSplitMode": "pages",
            "maximumPageLength": 1000,
            "pageOverlapLength": 250,
            "defaultLanguageCode": "ja",
            "inputs": [
                {
                    "name": "text",
                    "source": "/document/normalized_images/*/text"
                }
            ],
            "outputs": [
                {
                    "name": "textItems",
                    "targetName": "chunks"
                }
            ]
        },
        {
            "@odata.type": "#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill",
            "context": "/document/normalized_images/*/chunks/*",
            "resourceUri": "",
            "deploymentId": "text-embedding-3-large",
            "apiKey": "",
            "inputs": [
                {
                    "name": "text",
                    "source": "/document/normalized_images/*/chunks/*"
                }
            ],
            "outputs": [
                {
                    "name": "embedding",
                    "targetName": "vector"
                }
            ]
        }
    ],
    "indexProjections": {
        "selectors": [
            {
                "targetIndexName": "",
                "parentKeyFieldName": "parentKey",
                "sourceContext": "/document/normalized_images/*/chunks/*",
                "mappings": [
                    {
                        "name": "page",
                        "source": "/document/normalized_images/*/pageNumber"
                    },
                    {
                        "name": "text",
                        "source": "/document/normalized_images/*/chunks/*"
                    },
                    {
                        "name": "textVector",
                        "source": "/document/normalized_images/*/chunks/*/vector"
                    },
                    {
                        "name": "metadata_storage_path",
                        "source": "/document/metadata_storage_path"
                    },
                    {
                        "name": "metadata_storage_name",
                        "source": "/document/metadata_storage_name"
                    }
                ]
            }
        ],
        "parameters": {
            "projectionMode": "skipIndexingParentDocuments"
        }
    }
}
```

ここが一番重要。以下のスキルセットを実行している。

1. [Microsoft.Skills.Vision.OcrSkill](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-ocr) (PDFファイルからテキストを抽出)
2. [Microsoft.Skills.Text.SplitSkill](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-textsplit) (テキストをチャンク分割)
3. [Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-azure-openai-embedding) (各チャンクをベクトル化)

OCRスキルで PDF ファイルからテキストを抽出している。ここは[ドキュメント抽出スキル](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-document-extraction)でも良いのかもしれない(今度試してみる)

チャンク分割スキルでは分割対象のテキストを指定すると、指定した文字数＆重複文字数でチャンク分割してくれる。```defaultLanguageCode```が重要で、```ja```(日本語)に指定しておくと、句読点とかで良い感じに分割してくれる。```textSplitMode```は```pages```か```sentences```のどちらかを指定することができる。```sentences```を指定するとやたら短く分割されるようになるので、```pages```を指定した方が良い気がする。重要なのは、ちゃんと```context```を指定することで、このスキルの前段階で実行されるOCRスキルでは、ページごとの抽出結果が文字列の配列で出力されるので、この```context```で配列内の文字列ごとにチャンク分割処理をすることを明示しないとエラーになる (指定しないと文字列配列がチャンク分割スキルに渡されるので)。

> 難解で仕方がない context の仕様を理解するためには以下のドキュメントを読むと良いかもしれない
> [スキル コンテキストと入力注釈の参照言語 - Azure AI Search | Microsoft Learn](https://learn.microsoft.com/ja-jp/azure/search/cognitive-search-skill-annotation-language)

ベクトル化スキルではチャンク分割したテキストをベクトル化している。ここでも```context```をちゃんと設定して配列内のテキストごとに処理するようにさせる。```resourceUri```には使用する Azure OpenAI Service のエンドポイント(例：```https://xxx.openai.azure.com```)を、```apiKey```にはその認証キーを指定する。マネージドID認証もできるはず。

特に重要なのが、インデックスプロジェクションの設定。```indexProjections```以下で設定することになる。なぜ重要かというと、Azure AI Search で Azure Blob Storage のデータをインデクサー経由で JSON, JSONL, CSV 以外のファイル取り込んだ場合、1ファイル1検索ドキュメントとしてインデックスに登録される、という仕様になっているため。この場合、たとえ 100 ページ以上の PDF ファイルであっても1検索ドキュメントとして登録されてしまう(OCRスキルを使用した場合はページごとにテキスト抽出がされるので、文字列の配列が対象のインデックスのフィールドに格納されることになる)。普通に考えて文字列の配列が1検索ドキュメントに格納されるのではなく、複数の検索ドキュメントに分かれて格納されることだと思われる。新登場のインデックスプロジェクションはこの問題を解決することができる。

> 参考：[インデックス プロジェクションの概念 - Azure AI Search | Microsoft Learn](
https://learn.microsoft.com/ja-jp/azure/search/index-projections-concept-intro?tabs=kstore-rest)

インデックスプロジェクションでは主にフィールドマッピングを行う。ここでフィールドマッピングをしておくと、インデクサーでマッピングを行う必要がなくなる。なぜか対象インデックスも指定しないといけない(イマイチな仕様)。一方で、対象インデックスとマッピングの定義先である```selectors```は配列であるため、理論上、複数のインデックスに格納するといったことができると思われる(今度試してみる)。```parentKeyFieldName```には格納先のインデックスで用意しておいた親ドキュメントのキーを格納するフィールド名(今回だと```parentKey```)を指定する。```parameters.projectionMode```では親ドキュメントもインデックスに格納するかどうかを指定できる。今回は親ドキュメントを作成しない(```skipIndexingParentDocuments```)にしておいているが、親ドキュメントを作成しておくと兄弟ドキュメントが検索しやすくなる、みないなことができるのでは？と推測する。

参考：
[Skillsets - Create - REST API (Azure Search Service) | Microsoft Learn
](https://learn.microsoft.com/ja-jp/rest/api/searchservice/skillsets/create?view=rest-searchservice-2023-10-01-preview)

## JSONファイルからインデクサー等を作成する方法
Azure Portal で作成するか、[REST API](https://learn.microsoft.com/ja-jp/rest/api/searchservice/operation-groups?view=rest-searchservice-2023-10-01-preview) で作成する。  
Pythonで作成する簡単な例は以下の通り。

```python
import requests

search_name = ""
api_key = ""
api_version = "2023-10-01-Preview"
headers = {"Content-Type": "application/json", "api-key": api_key}

with open("index.json", "r") as f:
    data = f.read()

url = f"https://{search_name}.search.windows.net/indexes?api-version={api_version}"
requests.post(url, data=data, headers=headers)
```

curl で作成する簡単な例は以下の通り。

```bash
curl -X PUT https://$cognitiveSearchName.search.windows.net/indexes/$cognitiveSearchIndexName?api-version=2023-10-01-Preview \
    -H 'Content-Type: application/json' \
    -H 'api-key: '$cognitiveSearchApiKey \
    -d @index.json
```