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および使用しているパッケージのリストと、それらのバージョンを示すコマンドおよび結果は以下の通りです。
% sw_vers
ProductName: macOS
ProductVersion: 13.4.1
% python --version
Python 3.11.5
% pip freeze
numpy==1.25.2
sqlite-vss==0.1.2
% sqlite3 --version
3.41.2 2023-03-22 11:56:21 0d1fc92f94cb6b76bffe3ec34d69cffde2924203304e8ffc4155597af0c191da
% 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を訓練した場合もエラーが発生しました。
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
を表示するプログラムです。
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 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 内の、エラーの直接の原因となった箇所を以下に示します。
##############################################
# 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と全く同じなので省略しています。
# ... 省略 ...
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内でバイナリに変換したほうが安全かと思います。
では、実際にエンコードの結果が違うことを確かめてみましょう。以下のコードを実行してみます。
# -*- 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のバイナリ化はちょっと違うのです。さらに、扱うベクトルの数や次元数が増えると、何らかのエラーが発生する可能性があるようです。
まとめ
以上のコードは、こちらに公開しておきます。自由にお使いください。
今回のこのエラーの原因、調べるのに結構時間がかかりました。エンコードとデコードをするライブラリを揃えるのは基本的なことであり、大体の場合はそうした方が良いでしょうね。そうすれば最初からこんなに苦労することはなかったと思います。
筆者と同じエラーに遭遇した方の参考になれば幸いです。