GitHubのトレンドを眺めていたら、zvec というのが目に留まった。Alibabaが出しているオープンソースのベクタDBで、説明に「in-process vector database(プロセス内で動くベクタDB)」とある。要はMilvusやQdrantのようにサーバープロセスを別建てするのではなく、SQLiteのようにアプリケーションに直接埋め込んで使う、という設計思想らしい。
私はふだんインフラ寄りの立場でこの手のミドルウェアを見ているので、「サーバーが要らない」という一点にまず反応してしまう。ベクタDBをRAGのために立てると、たいていは常駐プロセス+そのための監視+バージョン追従がついてくる。運用を考えると、この常駐コストが地味に効いてくるんですよね。それがライブラリのimportだけで済むなら、小〜中規模の用途ではかなり魅力的です。
というわけで、実際に動かしてどこまで「サーバー不要」が本当なのかを確かめてみました。
試した環境
ホストを汚したくないので、検証は使い捨てのDockerコンテナの中だけで完結させています。ホスト(Windows)には何もインストールせず、コンテナを立てて→動かして→壊す、という切り分けです。
| 項目 | 値 |
|---|---|
| ベースイメージ | python:3.12-bookworm |
| Python | 3.12.13 |
| zvec | 0.5.1(PyPI wheel、C++コアのバイナリ同梱) |
| 依存 | numpy 2.5.1 のみ |
導入は拍子抜けするほど速い。pip install zvec が実測で約4.9秒、ダウンロードするwheelは19.7MBで、依存はnumpiだけ。外部サービスもコンパイルも要らず、そのままimport zvecできます。この「立ち上がりの軽さ」は、公式が謳っている no servers, no config の通りでした。
$ pip install zvec
Downloading zvec-0.5.1-cp312-cp312-manylinux_2_28_x86_64.whl (19.7 MB)
Successfully installed numpy-2.5.1 zvec-0.5.1
# real 0m4.863s
まずは公式のワンミニッツ例——だが最初に非推奨警告
READMEの「One-Minute Example」をそのまま写経して動かします。4次元のベクタを2件入れて、近いものを引く、という最小構成です。
import zvec
schema = zvec.CollectionSchema(
name="example",
vectors=zvec.VectorSchema("embedding", zvec.DataType.VECTOR_FP32, 4),
)
collection = zvec.create_and_open(path="./zvec_example", schema=schema)
collection.insert([
zvec.Doc(id="doc_1", vectors={"embedding": [0.1, 0.2, 0.3, 0.4]}),
zvec.Doc(id="doc_2", vectors={"embedding": [0.2, 0.3, 0.4, 0.1]}),
])
results = collection.query(
zvec.VectorQuery("embedding", vector=[0.4, 0.3, 0.3, 0.1]),
topk=10,
)
print(results)
結果はこうなりました(クエリ自体は0.05秒ほど)。
DeprecationWarning: VectorQuery is deprecated and will be removed in a future version. Use Query instead.
[{"id": "doc_2", "score": 0.30000001192092896, ...},
{"id": "doc_1", "score": 0.23000001907348633, ...}]
動くには動くんですが、公式READMEに載っている VectorQuery が、実際に叩くと「非推奨だから Query を使え」と警告してくる。ドキュメントの例文が実装より一歩遅れている、というのはよくある話ですが、初手で警告が出るのは少し身構えます。ちなみにスコアの 0.30 は doc_2 のベクタとクエリの内積そのもの(0.4×0.2+0.3×0.3+0.3×0.4+0.1×0.1)なので、デフォルトのメトリックは内積(IP)だと分かります。距離系のメトリックは MetricType.COSINE / IP / L2 から選べるようになっていました。
推奨されている Query に置き換えると、警告なしで同じことができます。こちらが正しい書き方ですね。
# 非推奨の VectorQuery ではなく Query を使う
results = collection.query(
zvec.Query("embedding", vector=[0.9, 0.1, 0.0, 0.0]),
topk=3, output_fields=["text"],
)
# id=a1 score=0.82 / id=a3 score=0.5 / id=a5 score=0.2
本命の全文検索(FTS)——コード例が無いので手探り
このバージョン(0.5系)の目玉は、ベクタ検索と同じ器の中でネイティブに全文検索(FTS)ができることらしい。「外部の検索エンジンを別建てしなくていい」というのは、まさにさっきの「サーバー不要」と同じ発想で、運用する側としては一番刺さるポイントです。
ところが、READMEにはFTSのコード例が載っていない。仕方ないので、インストール済みのパッケージをdir()とinspect.signature()で覗いて、APIを自分で組み立てました。分かったのは、文字列フィールドに FtsIndexParam を付けてインデックスを張り、検索時は Query(fts=Fts(...)) を渡す、という形です。
技術系の短文を5件入れて、キーワードで引いてみます。
schema = zvec.CollectionSchema(
name="articles",
fields=[
zvec.FieldSchema("text", zvec.DataType.STRING,
index_param=zvec.FtsIndexParam(tokenizer_name="standard", filters=["lowercase"])),
zvec.FieldSchema("category", zvec.DataType.STRING),
],
vectors=zvec.VectorSchema("embedding", zvec.DataType.VECTOR_FP32, 4,
index_param=zvec.FlatIndexParam()),
)
col = zvec.create_and_open(path="./zvec_fts", schema=schema)
# ... "Zvec is an in-process vector database..." など5件をinsert ...
col.query(zvec.Query("text", fts=zvec.Fts(match_string="vector database")),
topk=5, output_fields=["text"])
実際の出力がこちら。
=== FTS match_string='vector database' ===
id=a1 score=1.5698 text='Zvec is an in-process vector database for similarity search'
id=a4 score=0.9667 text='A slow legacy database migration story'
id=a3 score=0.8236 text='Hybrid retrieval fuses vector search and full text search'
キーワード検索らしい挙動がちゃんと出ています。面白いのは2番目で、「vector database」で検索したのに a4 の “A slow legacy database migration story” が引っかかっている。“database” というトークンを共有しているからで、BM25系のスコアリングだと当然こうなります。ベクタの意味検索に慣れた目には一瞬「なぜ?」と思うところですが、これはFTSとして正しい。意味検索とキーワード検索は別物だと再確認させられました。
ブール演算子も試しました。+search -slow(searchを含み、slowを除く)と書くと、“slow” を含む a4 がちゃんと落ちます。
=== FTS query_string='+search -slow' ===
id=a3 score=0.7104 / id=a2 score=0.5334 / id=a1 score=0.4832
Fts には自然文向けの match_string と、演算子付きの query_string の2系統があり、FtsQueryParam(default_operator="AND") で「並んだ単語をANDで結ぶかORで結ぶか」も切り替えられました。トークナイザも standard のほか jieba(中国語向け)が選べるようになっていて、このあたりはAlibaba発らしい作りだなと感じます。
ここで2回ハマった——フィルタ周り
短文検索まではスムーズだったんですが、「カテゴリで絞り込みつつ全文検索」をやろうとしたところで2回つまずきました。ここが今回いちばんの体験です。
最初は素直にPythonの感覚で filter="category == 'db'" と書きました。すると実行時に構文エラー。
ValueError: Invalid filter [category == 'db']: syntax error
... extraneous input '=' expecting {'OR','AND','NOT','IN','BETWEEN','LIKE', ...}
エラーメッセージをよく見ると、OR / AND / IN / BETWEEN / LIKE といったキーワードを期待している。つまりフィルタはPythonの式ではなく、SQL風のDSLなんですね。等価比較は == ではなく単一の = が正解でした。ここは好みが分かれるところですが、SQLに寄せた構文だと言われれば納得はいきます。
= に直したら構文エラーは消えた——のに、今度は結果が0件になりました。エラーは出ない。これが2つ目の罠で、しばらく悩みました。
原因は、フィルタ対象の category フィールドにインデックスを張っていなかったこと。FieldSchema に index_param=zvec.InvertIndexParam() を付けてスキーマを作り直したら、期待通りに返ってきました。
zvec.FieldSchema("category", zvec.DataType.STRING,
index_param=zvec.InvertIndexParam()) # ← これが無いとfilterが黙って0件
=== FTS 'search' + filter category = 'db' ===
id=a3 category='db' / id=a1 category='db'
正直なところ、この「インデックスが無いとエラーも警告も出さずに0件を返す」挙動は、運用に乗せる前に絶対に踏んでおきたい類のものです。開発中は気づけても、本番でデータ構造を変えたときに静かに検索が空振りする、というのは怖い。スキーマ設計の段階で「フィルタしたい列にはInvertIndex、全文検索したい列にはFtsIndex」を明示的に決めておく、という運用ルールにするのが無難だと感じました。
試してみての所感
半日ほど触った範囲での、運用目線の整理です。
| 観点 | 感触 |
|---|---|
| 導入コスト | pip install 4.9秒+依存numpiのみ。常駐プロセス無し。ここは文句なし |
| ベクタ+全文検索の一体化 | 同じスキーマ・同じクエリ器で両方引けるのは、外部検索エンジンを足さずに済む分だけ運用が軽い |
| ドキュメント | READMEはベクタ例のみ。FTSやフィルタは手探りが必要。バージョンの進みが速い分、追従が追いついていない印象 |
| 落とし穴 | フィルタのSQL風構文、無インデックス時の無言0件。設計段階で潰しておきたい |
私の結論としては、「サーバーを常駐させたくない小〜中規模のRAGや、アプリに検索を埋め込みたいケース」では十分に選択肢に入る、というところ。月額の常駐コストやスケーリングの運用を丸ごと省けるのは、規模が小さいうちは大きな利点です。一方で、ドキュメントがまだ実装に追いついていないので、本番投入するなら今回踏んだような挙動を自分たちで一通り確認してからにしたい。ここは様子見しつつ、次のマイナーバージョンでFTS周りの例が充実するかを見ていきたいと考えています。
もっと良い使い方や、大規模時の勘所をご存じの方がいたら教えてほしいです。