5
1

More than 1 year has passed since last update.

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

Posted at

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

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

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

エラーの再現方法

最初に、今回遭遇したエラーの再現方法を示します。Pythonおよび使用しているパッケージのリストと、それらのバージョンを示すコマンドおよび結果は以下の通りです。

OSのバージョン
% sw_vers
ProductName:		macOS
ProductVersion:		13.4.1
python
% python --version
Python 3.11.5
pythonのモジュール
% pip freeze
numpy==1.25.2
sqlite-vss==0.1.2
sqlite3
% sqlite3 --version
3.41.2 2023-03-22 11:56:21 0d1fc92f94cb6b76bffe3ec34d69cffde2924203304e8ffc4155597af0c191da
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を訓練した場合もエラーが発生しました。

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('プログラムが正常に実行されました。')

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

% 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を表示するプログラムです。

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になる

実行してみましょう。

% python check_nan.py 
False

Falseが出力されましたね。入力値に問題がないことがわかります。numpyのような広く用いられているライブラリの基本的な関数なので、当然と言えば当然でしょう。

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

  • Hugging Facesonoisa/sentence-bert-base-ja-mean-tokens-v2
  • Hugging Facesentence-transformers/paraphrase-multilingual-mpnet-base-v2
  • OpenAIembedding-ada-002-ise2023006

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

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

エラーの回避方法

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

詳しく見ていきましょう。
まず、エラーを再現するreproducing_the_error.py 内の、エラーの直接の原因となった箇所を以下に示します。

##############################################
# 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と全く同じなので省略しています。

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を実行してみましょう。

% python fixed_the_error.py 
プログラムが正常に実行されました。

無事にプログラムがエラーで止まらず、最後まで実行できることを確認できます。実行には数分かかります。あとはsqlite-vssのやり方に沿って検索をかけることができます。

エラーの原因

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

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

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

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

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内でバイナリに変換したほうが安全かと思います。

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

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というモジュールをインストールしておきましょう。バイナリを確認するために必要です。

% pip install bitarray

実行しましょう。

% python compare_encoding_with_sqlite_and_numpy.py 
SQLite:  01110110000000011100110111001100110011000011110111001101110011000100110000111110
Numpy:   1100110111001100110011000011110111001101110011000100110000111110

実際に、異なるバイナリができているのが分かりますね。ただ、全く違うかといったらそうでもないです。Numpyでエンコードされたバイナリをちょっと横にずらすと・・・

SQLite:  01110110000000011100110111001100110011000011110111001101110011000100110000111110
Numpy:                   1100110111001100110011000011110111001101110011000100110000111110

SQLiteの頭にある0111011000000001を除けば、一致しています。これが、なぜこういう違いがあるのか、SQLiteのバイナリの頭にあるのは何なのか、等は筆者には分かりません。知っている方がいれば教えていただきたいです。

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

まとめ

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

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

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

5
1
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
5
1