sqlite-vssとnumpyのバイナリ化はちょっと違う

Pythonからsqlite-vssを使っていて、ベクトルの値の一部が非数もしくは無限と認識されてしまい、近似近傍探索の実行ができないエラーに遭遇しました。本記事ではその理由と解決方法を示します。なお、理由についてはある程度は調べてみましたが、100点の理解ではないのでそこはご承知ください。

本記事はPython, numpyおよびsqlite-vssについて基本的なことを知っている人向けの記事です。例えば以下の記事のことをわかっているという前提で解説します。

- sqlite-vss入門 (MURAOKA Taro氏)
https://zenn.dev/koron/articles/8925963f432361

- SQLiteでベクトル検索ができる拡張sqlite-vssを試す (西見 公宏氏)
https://note.com/mahlab/n/n5d59b19be573

これらの記事は筆者も大変お世話になりました。まだsqlite-vssを触ったことない人はこれらの記事をご覧になってください。

# エラーの再現方法
最初に、今回遭遇したエラーの再現方法を示します。Pythonおよび使用しているパッケージのリストと、それらのバージョンを示すコマンドおよび結果は以下の通りです。
```zsh:OSのバージョン
% sw_vers
ProductName:		macOS
ProductVersion:		13.4.1
```
```zsh:python
% python --version
Python 3.11.5
```

```zsh:pythonのモジュール
% pip freeze
numpy==1.25.2
sqlite-vss==0.1.2
```

```zsh:sqlite3
% sqlite3 --version
3.41.2 2023-03-22 11:56:21 0d1fc92f94cb6b76bffe3ec34d69cffde2924203304e8ffc4155597af0c191da
```

```zsh:sqlite-vss
% python -c 'import sqlite3; import sqlite_vss; con = sqlite3.connect(":memory:"); con.enable_load_extension(True); sqlite_vss.load(con); print(con.execute("SELECT vss_version();").fetchone()[0])'
v0.1.2
```

この環境で、ある程度大きな次元数のベクトルをこれまたある程度の数から、Product Quantization(PQ) で近似近傍探索を実行しようとする以下のコードを実行すると、エラーが発生します。内容は至ってシンプルで、numpyの乱数で3,000次元のベクトルを50,000個生成し、PQの訓練を実施しています。エラーは、その訓練の最中に発生します。なお、本記事ではPQの訓練をしていますが、そのほかにIVFを訓練した場合もエラーが発生しました。

```python:reproducing_the_error.py
import sqlite3
import sqlite_vss
import numpy as np


##############################################
# 1. SQLite3データベースのセットアップ
##############################################
con = sqlite3.connect(':memory:')
con.enable_load_extension(True)
sqlite_vss.load(con)

##############################################
# 2. ベクトルデータの生成
##############################################
np.random.seed(3939)   # 乱数シードを固定
n_samples, x_dim = 50000, 3000
X = np.random.uniform(-2, 2, (n_samples, x_dim))
X = X.astype(np.float32)

##############################################
# 3. テーブルの生成
##############################################
table_name = 'vectors'
vss_table_name = 'vss_pq_word'
con.execute(f'CREATE TABLE {table_name} (vector BLOB);')
con.execute('''
CREATE VIRTUAL TABLE {} using vss0 (
    vector({}) factory="PQ15,IDMap2"
);'''.format(vss_table_name, x_dim))

##############################################
# 4. ベクトルデータベースの構築および訓練
##############################################
for vector in X:
    con.execute(f'INSERT INTO {table_name} (vector) VALUES (?)',
                (vector.tobytes(), ))
con.commit()
con.execute(
    """
    INSERT INTO {} (operation, vector)
    SELECT 'training', vector FROM {}
    """.format(vss_table_name, table_name)
)
con.commit()

print('プログラムが正常に実行されました。')
```

以上のスクリプトを、実行すると、以下のようにエラーが発生するはずです。

```zsh
% python reproducing_the_error.py 
libc++abi: terminating due to uncaught exception of type faiss::FaissException: Error in void faiss::Clustering::train_encoded(idx_t, const uint8_t *, const Index *, Index &, const float *) at /Users/alex/actions/sqlite-vss-runner/_work/sqlite-vss/sqlite-vss/vendor/faiss/faiss/Clustering.cpp:304: Error: 'std::isfinite(x[i])' failed: input contains NaN's or Inf's
zsh: abort      python reproducing_the_error.py
```

長いので、大事な箇所を以下のように抜き出します。
> Error: 'std::isfinite(x[i])' failed: input contains NaN's or Inf's

なんと、**入力値が非数(NaN)か無限(Inf)になっているではありませんか。** 通常であれば、入力されたベクトルの中身を疑いますが、ここではNumpyの`random.uniform`関数でベクトル内の値を乱数で決定しています。

一応、同様の方法で乱数を生成して、NaNが含まれていないかチェックしてみましょう。以下のプログラムは、上述したreproducing_the_error.py と全く同じ乱数のベクトル群を生成し、そのベクトル群内のベクトルの中に一つでもNaNが含まれていると`True`を出力し、全くない場合は`False`を表示するプログラムです。
```python:check_nan.py
import numpy as np
np.random.seed(3939)
n_samples, x_dim = 50000, 1000
X = np.random.uniform(-2, 2, (n_samples, x_dim))
X = X.astype(np.float32)
print(any(any(np.isnan(x)) for x in X)) # Xに一つでもnanが含まれていればTrueになる
```
実行してみましょう。
```zsh
% python check_nan.py 
False
```
`False`が出力されましたね。入力値に問題がないことがわかります。numpyのような広く用いられているライブラリの基本的な関数なので、当然と言えば当然でしょう。

実はこのエラー、reproducing_the_error.pyでは乱数で生成したベクトルで発生していますが、LLM(Large Language Model)等でテキストをエンコードして生成されたベクトルでも発生することがあります。筆者の肌感覚ですが、特に大きな次元、多くのサンプルで発生する確率が高いです。以下に筆者が確認したLLMのモデルを箇条書きしておきます。

- `Hugging Face`の`sonoisa/sentence-bert-base-ja-mean-tokens-v2`
- `Hugging Face`の`sentence-transformers/paraphrase-multilingual-mpnet-base-v2`
- `OpenAI`の`embedding-ada-002-ise2023006`

これらのLLMモデルは、サンプル数を増やせば増やすほど発生する可能性が高く、少量だと発生しないことが多いです。これは筆者の推測ですが、sqlite-vss(の中のfaiss)の処理中に、ベクトル内の値を偶発的にNaNかInfと誤って認識してしまうことが考えられます。訓練の計算過程で、NaNかInfが発生するバグの可能性もありますが、ここではそれについては考えていません。

以上がエラーの再現方法となります。以降では、このエラーの解決方法を示し、その原因として考えられることを説明します。


# エラーの回避方法
エラーを回避するためには、SQLiteのテーブルにベクトルを保存する方法を変更する必要があります。ベクトルをnumpyのtobytesメソッドでバイナリに変換しSQLiteのテーブルに保存していますが、これをやめます。そして、ベクトルをJSONのリストの文字列として保存し、SQLite内でバイナリに変換します。

詳しく見ていきましょう。
まず、エラーを再現するreproducing_the_error.py 内の、エラーの直接の原因となった箇所を以下に示します。
```python
##############################################
# 4. ベクトルデータベースの構築および訓練
##############################################
for vector in X:
    con.execute(f'INSERT INTO {table_name} (vector) VALUES (?)',
                (vector.tobytes(), ))
con.commit()
```
この部分の、vector.tobytes()が良くなかったです。これを、以下のようfixed_the_error.pyという名前のコードとして書き換えます。前後はreproducing_the_error.pyと全く同じなので省略しています。

```python:fixed_the_error
# ... 省略 ...
for vector in X:
    con.execute(f'INSERT INTO {table_name} (vector) VALUES (?)',
                (str(list(vector)), ))
con.commit()
con.execute(
    '''
    UPDATE {} SET vector = vector_to_blob(vector_from_json(vector));
    '''.format(table_name)
)
con.commit()
# ... 省略 ...
```
重要なのは、省略行を除いた3行目の`(str(list(vector)), ))`と、末尾に追加した6行です。前者は、numpyのベクトル(numpy.array)を、JSONのリストを表す文字列に変換し、保存しています。そして、末尾の処理で、すべてのベクトルを、SQLite3内でベクトルのバイナリに変換しています。つまり、**バイナリへの変換をPythonのnumpyで行うのではなく、SQLiteに(厳密には拡張機能のsqlite-vectorに)任せることでエラーを回避できます。** この方法は、記事の冒頭で紹介した、MURAOKA Taro氏が執筆した記事である『sqlite-vss入門』に合わせています。

修正版の`fixed_the_error.py`を実行してみましょう。
```zsh
% python fixed_the_error.py 
プログラムが正常に実行されました。
```
無事にプログラムがエラーで止まらず、最後まで実行できることを確認できます。実行には数分かかります。あとはsqlite-vssのやり方に沿って検索をかけることができます。


# エラーの原因
エラーの解決方法は説明しました。では、このエラーの原因についてみていきましょう。

ベクトルのバイナリへの変換（エンコード）の方法を、numpyのtobytesメソッドから、SQLite内の関数に切り替えることでエラーを回避できました。つまり、これらのバイナリへのエンコードと、バイナリからのデコードの規則が違うことが考えられます。これ、こういってしまえば当たり前かと思います。それぞれ違うツールなので、エンコードとデコードが対応していないのはおかしいことではありません。ベクトル検索される際は、SQLite内でデコードされるはずなので、SQLite内の機能でエンコードすること、つまり**エンコードとデコードのツールを同じにした方が良いというのは、自明とも言えます。**

ではなぜこのようなエラーが発生してしまったかというと、**numpyのtobytesメソッドを使うのは、公式ドキュメントに書かれている方法だからです・・・。**


https://alexgarcia.xyz/sqlite-vss/python.html

こちらが、sqlite-vssをpythonから使う場合のドキュメントです。numpyのArrayをsqlite-vssに取り込むコードを以下に引用します。

```python
import numpy as np

embedding = np.array([0.1, 0.2, 0.3])

db.execute(
    "insert into vss_demo(a) values (?)", [embedding.astype(np.float32).tobytes()]
)
```

numpyのtobytesメソッドを使っていますね。一応小規模であれば機能しているようには見えますが、今のところは上述したように、SQLite内でバイナリに変換したほうが安全かと思います。

では、実際にエンコードの結果が違うことを確かめてみましょう。以下のコードを実行してみます。

```python:compare_encoding_with_sqlite_and_numpy.py
# -*- coding: utf-8 -*-
import numpy as np
import sqlite3
import sqlite_vss
import json
from bitarray import bitarray

vector = [0.1, 0.2]

# SQLite側でバイナリに変換
con = sqlite3.connect(':memory:')
con.enable_load_extension(True)
sqlite_vss.load(con)
con.execute('CREATE TABLE demo(json_vector TEXT,vector BLOB);')
con.execute('INSERT INTO demo(json_vector) VALUES (?)',
            (json.dumps(vector), ))
con.execute('UPDATE demo SET vector = vector_to_blob(vector_from_json(json_vector));')
sqlite_vector_bytes = con.execute('SELECT * FROM demo').fetchone()[1]
bits = bitarray(endian='big')
bits.frombytes(sqlite_vector_bytes)
print('SQLite: ', bits.to01())

# numpyでバイナリに変換
np_bytes = np.array(vector).astype(np.float32).tobytes()
bits = bitarray(endian='big')
bits.frombytes(np_bytes)
print('Numpy:  ', bits.to01())
```

実行する前に、以下のコマンドでbitarrayというモジュールをインストールしておきましょう。バイナリを確認するために必要です。
```zsh
% pip install bitarray
```

実行しましょう。

```zsh
% python compare_encoding_with_sqlite_and_numpy.py 
SQLite:  01110110000000011100110111001100110011000011110111001101110011000100110000111110
Numpy:   1100110111001100110011000011110111001101110011000100110000111110
```

実際に、異なるバイナリができているのが分かりますね。ただ、全く違うかといったらそうでもないです。Numpyでエンコードされたバイナリをちょっと横にずらすと・・・
```
SQLite:  01110110000000011100110111001100110011000011110111001101110011000100110000111110
Numpy:                   1100110111001100110011000011110111001101110011000100110000111110
```
SQLiteの頭にある`0111011000000001`を除けば、一致しています。これが、なぜこういう違いがあるのか、SQLiteのバイナリの頭にあるのは何なのか、等は筆者には分かりません。知っている方がいれば教えていただきたいです。

というわけで、sqlite-vssとnumpyのバイナリ化はちょっと違うのです。さらに、扱うベクトルの数や次元数が増えると、何らかのエラーが発生する可能性があるようです。


# まとめ
以上のコードは、こちらに公開しておきます。自由にお使いください。

https://github.com/HidetoshiKawaguchi/tech-blog-codes/tree/main/20230927_encoding-with-sqlite-vss-or-numpy

今回のこのエラーの原因、調べるのに結構時間がかかりました。エンコードとデコードをするライブラリを揃えるのは基本的なことであり、大体の場合はそうした方が良いでしょうね。そうすれば最初からこんなに苦労することはなかったと思います。

筆者と同じエラーに遭遇した方の参考になれば幸いです。