Azure AI Search にバイナリ ベクトル型のデータを追加してベクトル検索する

[Microsoft Build 2024 でのアップデート](https://qiita.com/nohanaga/items/54e74cea41a278c47a5c)で Azure AI Search においてバイナリ ベクトル型での保存・検索が可能になりました。今回は Azure Machine Learning/Azure AI Studio の[モデルカタログを使用](https://qiita.com/nohanaga/items/fd04e2223b8a237cf335)して Cohere-embed-v3-multilingual をデプロイして Azure AI Search でバイナリ ベクトル検索を実施します。

# デプロイ
https://qiita.com/nohanaga/items/fd04e2223b8a237cf335

# Cohere-embed-v3-multilingual でバイナリ ベクトルを取得
```python
response = co.embed(
    texts=[text], 
    input_type=input_type,
    embedding_types=["ubinary"]
)
response.embeddings.ubinary[0] 
```

`embedding_types=["ubinary"]` と指定することで、`uint8` にパックされたバイナリ ベクトルが取得できます。
```python
array([218, 123,  28,  17,  74,  23, 231, 217, 255, 214,  10, 218, 129,
       239, 189,  99, 114, 159, 251,  38,  28, 104, 124, 236, 107, 179,
        69,  72, 105, 173,  64, 183, 210,  74, 117, 176, 173, 182,  57,
       241,  16, 247,  28,  27,   4, 135, 128,   2, 207, 230, 139,   9,
        67,  46, 248, 103,   8,  40,  45,  51,  71, 150, 142,  49,  78,
       113,  68, 177, 132, 254, 152, 128, 131,  32, 215,  87, 174, 201,
        31, 131,  32, 122, 221, 187, 245,  84,  22, 204, 236, 192,  40,
        94,  46,  98,  79,  89,   9,  67, 191, 170, 153, 219, 216,  64,
       166, 144,  88, 197, 211,   8,  63,  21, 129,  92, 123,  74,  24,
        44,  79,   6, 102,  81, 239, 111,  67, 158, 104,  38])
```

この長さ 128 の `uint8` 配列をそのまま Azure AI Search に投入できます。


## Cohere Embeddings モデルの仕様
> バイナリ Embeddings は、1024 個の float32 値を 1024 ビット値に変換し、メモリを 32 分の 1 に削減します。1024 ビットの転送と保存は非効率であるため、これらの 1024 ビット値は 128 バイトにパックされ、符号付き int8 値または符号なし uint8 値として取得できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/82f174ae-9077-2f50-6b01-cd5e54883346.png)
(https://cohere.com/blog/int8-binary-embeddings)

この手法を用いれば、OpenAI の Embeddings モデルもバイナリベクトル化できます。

## 【参考】 ビットのパックとは
ビットの圧縮手法として、Packbits という手法が使えます。2 値配列 (要素が 0, 1 のみの配列) の要素を、`uint8` 配列のビットに詰め込みます。解凍には `unpackbits` が使えます。

```python
import numpy as np

array = np.array([0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0], dtype=np.uint8)
packed_bin = np.packbits(array)
packed_bin
```

> array([  0, 255, 128], dtype=uint8)

# インデックス作成
Azure AI Search の `Collection(Edm.Binary)` フィールドは、1 次元あたり 1 ビットのバイナリ ベクトルを値あたり 8 ビットの `uint8` 値でパックして保持します。たとえば、元の埋め込みの次元が 1024 である場合、パックされたバイナリ ベクトルの長さは `ceiling(1024 / 8) = 128` になります。 

## fields
`type` を `Collection(Edm.Binary)`、`vectorEncoding` を `packedBit` に設定する必要があります。`dimensions` に注意してください。実際の配列の要素数ではありません。解凍後の次元数です。
```json
"fields": [ 
    { 
      "name": "Id", 
      "type": "Edm.String", 
      "key": true, 
      "searchable": true 
    }, 
    { 
      "name": "my-binary-vector-field", 
      "type": "Collection(Edm.Byte)", 
      "vectorEncoding": "packedBit", 
      "dimensions": 1024, 
      "vectorSearchProfile": "myHnswProfile" 
    } 
]
```

## algorithms
バイナリ ベクトル フィールドの場合、ベクトル比較はハミング距離 `hamming` を使います。アルゴリズムは `HNSW` および `Exhaustive-KNN` で使用できます。

```json
   "algorithms": [
      {
        "name": "vector-config-1716115650482",
        "kind": "hnsw",
        "hnswParameters": {
          "metric": "hamming",
          "m": 4,
          "efConstruction": 400,
          "efSearch": 500
        },
        "exhaustiveKnnParameters": null
      }
    ],
```

ハミング距離（Hamming Distance）は、2 つの同じ長さの文字列（またはビット列）の間で、対応する位置の文字が異なる数を表す指標です。具体的には、2 つの文字列の各位置を比較し、異なる文字の数を数えます。

```python
a = np.array([1, 1, 0, 1])
b = np.array([1, 0, 1, 1])
np.count_nonzero(a != b)

2
```

## vectorizer
vectorizer を指定すれば自動的に Embeddings に変換してベクトル検索できます。
```json
"vectorizers": [
  {
    "name": "vector-1716106525833-vectorizer",
    "kind": "customWebApi",
    "azureOpenAIParameters": null,
    "customWebApiParameters": {
      "httpMethod": "POST",
      "uri": "[FunctionURI]/api/embeddings?embedding_types=ubinary&code=xxx",
      "timeout": "PT3M50S",
      "authResourceId": "",
      "httpHeaders": {},
      "authIdentity": null
    },
    "aiServicesVisionParameters": null,
    "amlParameters": null
  }
],
```

# インデックス登録
REST API は `2024-05-01-preview`バージョンの [CreateOrUpdateIndex](https://learn.microsoft.com/ja-jp/rest/api/searchservice/indexes/create-or-update?view=rest-searchservice-2024-05-01-preview&preserve-view=true&tabs=HTTP) を使用。Python SDK は `11.6.0b4` を使用して直接挿入しました。


# ベクトル検索
## vectorizer 使用
vectorizer をセットした場合は、`VectorizableTextQuery` にクエリーテキストを指定して検索できます。

```python
query = "ABC"
vector_query = VectorizableTextQuery(text=query, k_nearest_neighbors=top, fields=vector_field, exhaustive=False)

docs = search_client.search(
    search_text="",
    vector_queries= [vector_query],
    select=["text,docid"],
) 
```

## ベクトル配列
従来通り配列を与える場合は `VectorizedQuery` を用います。
```python
vector = [26, 126, ...]
vector_query = VectorizedQuery(kind="vector", vector=vector, k_nearest_neighbors=top, fields=vector_field)

docs = search_client.search(
    search_text="",
    vector_queries= [vector_query],
    select=["text,docid"],
) 
```

# 検索スコア
`@search.score` にはハミング距離 `hamming` が使われますが、`0.007194245` のような値が返ります。これは `cosine` と同様、`1 / (1 + distance)` のスケーリングが掛けられていますね。これはハミング距離が増加するとスコアが単調に減少するようなスケーリング変換です。自分でベクトルを `unpackbits` して計算したハミング距離を `1 / (1 + distance)` することで等しくなることを確認できます。

## 閾値の設定
ハミング距離に閾値を設定するには、以下のように変換すれば分かりやすいですね。
 ```python
def score_to_hamming_distance(x):
    if x <= 0:
        raise ValueError("x must be greater than 0")
    return (1 - x) / x
 ```

# 精度比較
[前回](https://qiita.com/nohanaga/items/e159712acbf80e40ca1a#%E7%B5%90%E6%9E%9C)同様、miracl 日本語 QA データ 8,066 件による比較。

| | float32|int8|binary
|:-:|:-:|:-:|:-:|
|#1 Recall@3 avg|0.652|0.668|0.672
|#1 Recall@5 avg|0.786|0.786|0.751
|#2 Recall@3 avg|0.686|0.696|0.649
|#2 Recall@5 avg|0.817|0.813|0.778

#1 Recall@3 以外はバイナリにすると精度が落ちます。これは大幅なベクトルメモリーの削減やパフォーマンス向上とのトレードオフですね。ただバイナリはリランクと組み合わせる手法によって精度向上も可能なので、こちらも試したいです。

# パフォーマンス比較
https://qiita.com/nohanaga/items/07236a87b6bc72ca83ee

# 制限
現在はバイナリ ベクトル型へのスカラー量子化機能やリランク機能は実装されていません。

# 参考
https://learn.microsoft.com/azure/search/vector-search-how-to-index-binary-data

https://qiita.com/nohanaga/items/54e74cea41a278c47a5c