Faiss-node + LangChainでEmbeddingしたベクトルデータをAmazon S3に保存・ロードする
FAISSを使用することで、Embeddingしたベクトルデータをローカルファイルとして保存・読み込みができるようになります。しかし、ファイル保存先をAmazon S3に置きたいという要望があります。この記事では、S3にFAISSのインデックスを配置する方法を試行錯誤しながらまとめています。また、具体的な保存や読み込みの処理も提供されています。S3以外にもCloudflare WorkersやFirebaseなどでも利用可能です。
目次
FAISSを利用することで、OpenAIやClaudeなどでEmbeddingしたベクトルデータをローカルファイルとして保存・読み込みができるようになります。個人的にはベクトルDB版のSQLite的な手軽さがあるのが非常にありがたいOSSです。ただ人間は欲深いもので、「ファイルとして保存できる」となると今度は「Amazon S3に置きたいな」となってきました。
Amazon S3にFAISSのインデックスを配置するための試行錯誤を以下にまとめます。
現状のFAISS-nodeでは、ローカルにしかファイルを配置できない
そもそもライブラリにオプションがあればそれで解決・・・なのですが、コードを見るとそもそもローカルにファイルを配置することしか想定していない様子でした。fs
を使っているので、Cloudflare Workers / Pages functionsで使うのも難しそうです。
/**
* Saves the current state of the FaissStore to a specified directory.
* @param directory The directory to save the state to.
* @returns A Promise that resolves when the state has been saved.
*/
async save(directory: string) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
await fs.mkdir(directory, { recursive: true });
await Promise.all([
this.index.write(path.join(directory, "faiss.index")),
await fs.writeFile(
path.join(directory, "docstore.json"),
JSON.stringify([
Array.from(this.docstore._docs.entries()),
this._mapping,
])
),
]);
}
車輪の再開発で挙動を把握する
コードを読む限り、save
もload
もそれ自体はすごく難しい処理をしている様子もありませんでした。と、いうことは挙動を再現できればS3にインデックスを配置することもできそうです。
インデックスを保存する処理を自力で実装してみた
ということで、まずはsave
を使わずに同様の処理を実装してみました。ディレクトリ生成やライブラリのimport処理・パス指定などのオプションは考慮せず、シンプルに実装します。
const vectorStore = await FaissStore.fromTexts(
["Hello world", "Bye bye", "hello nice world"],
[{ id: 2 }, { id: 1 }, { id: 3 }],
new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY
})
);
const faissIndex = vectorStore.index.toBuffer()
fs.writeFileSync('./vector-store/manual/faiss.index', faissIndex);
fs.writeFileSync('./vector-store/manual/docstore.json', JSON.stringify([
Array.from(vectorStore.docstore._docs.entries()),
vectorStore._mapping,
]));
docstore.json
がすこし変わったスキーマになっている点に注意するくらいで、要はFaissStore
をJSONとBufferでそれぞれエクスポートしているだけです。fs.writeFileSync
をAWS S3のputObject
に変えれば動きそうですね。
インデックスを読み込む処理も再開発する
ファイルを配置できても、読み込めなければ使えません。ということでload
側も再開発してみました。利用するライブラリはこの辺りです。
import { FaissStore } from "langchain/vectorstores/faiss";
import { OpenAIEmbeddings } from "langchain/embeddings/openai";
import { SynchronousInMemoryDocstore } from "langchain/stores/doc/in_memory"
import path from 'path';
import fs from 'fs';
そして実装側はこのような形になりました。2つのファイルを読み込み、FaissStore
クラスを作る処理をベタ書きで実装しています。
faissApp.get('load', async c => {
const storeJSON = fs.readFileSync('./vector-store/manual/docstore.json', 'utf-8')
const indexData = fs.readFileSync('./vector-store/manual/faiss.index')
const [docstoreFiles, mapping] = JSON.parse(storeJSON)
const docstore = new SynchronousInMemoryDocstore(new Map(docstoreFiles));
const { default: {
IndexFlatL2
}} = await import('faiss-node')
const index = IndexFlatL2.fromBuffer(indexData)
const vectorStore = new FaissStore(
new OpenAIEmbeddings({
openAIApiKey: env.openai.apiKey
}),
{
docstore,
index,
mapping
}
)
const resultOne = await vectorStore.similaritySearch("hello world", 1);
return c.json(resultOne)
});
こちらも「ファイルの中身が意図した形式で読めれば、fs
以外からでも読み込みできる」実装であることが伺えます。
Amazon S3(AWS SDK v3)と接続する
動きがだいたいわかったので、いよいよS3とつなぎます。まずはSDKをいれましょう。
npm i @aws-sdk/client-s3
保存先をS3に変更する
先ほどのコードをS3のPutObject
に差し替えるだけです。オブジェクトストレージなので、ディレクトリ生成処理まわりを意識せずに済むのはよさそうですね。
const vectorStore = await FaissStore.fromTexts(
["Hello world", "Bye bye", "hello nice world"],
[{ id: 2 }, { id: 1 }, { id: 3 }],
new OpenAIEmbeddings({
openAIApiKey: env.openai.apiKey
})
);
const faissIndex = vectorStore.index.toBuffer()
await s3.send(
new PutObjectCommand({
Bucket: "S3_BUCKET_NAME",
Key: "langchain/faiss/faiss.index",
Body: faissIndex
})
)
await s3.send(
new PutObjectCommand({
Bucket: "S3_BUCKET_NAME",
Key: "langchain/faiss/docstore.json",
Body: JSON.stringify([
Array.from(vectorStore.docstore._docs.entries()),
vectorStore._mapping,
])
})
)
S3に保存したデータを読み込みする
読み込み側も同様にfs
をs3:GetObject
に差し替えればOKです。GetObject
で取得したデータを、docstore.json
はtransformToString
&JSON.parse
で、faiss.index
はtransformToByteArray
&Buffer.concat()
で処理しています。
const s3DocstoreData = await s3.send(
new GetObjectCommand({
Bucket: "S3_BUCKET_NAME",
Key: "langchain/faiss/docstore.json",
})
)
const docstoreData = await s3DocstoreData.Body?.transformToString()
const [docstoreFiles, mapping] = JSON.parse(docstoreData || '')
const docstore = new SynchronousInMemoryDocstore(new Map(docstoreFiles));
const s3IndexData = await s3.send(
new GetObjectCommand({
Bucket: "S3_BUCKET_NAME",
Key: "langchain/faiss/faiss.index",
})
)
if (!s3IndexData.Body) {
return c.json([])
}
const { default: {
IndexFlatL2
}} = await import('faiss-node')
const indexData = await s3IndexData.Body?.transformToByteArray()
const index = IndexFlatL2.fromBuffer(Buffer.concat([indexData]))
const vectorStore = new FaissStore(
new OpenAIEmbeddings({
openAIApiKey: env.openai.apiKey
}),
{
docstore,
index,
mapping
}
)
やってみて
faiss-node
が各種プロパティをprivate
などでアクセスできないようにしているわけではなかったため、外からでもsave
とload
の挙動を再現できたのがかなり助かりました。ライブラリ自体がTypeScriptではない様子なので、そもそも設定できない様子だということもありますが・・・
本体側に取り込んでもらえると嬉しい気持ちもありますが、いろいろとベタ書きしている部分をオプション化したり、他のメソッドとの整合性を考えたりしないとですので、Pull Requestについては少し考えてから出すようにしたいと思います。
あと、理論上Cloudflare R2とかfirebase / GCPでもやれると思いますので、どなたかぜひ挑戦してみてください。