ベクトル検索を1つ足すために、コンテナをもう1本立てる。RAGの構成図がだいたいそうなっているのは、ベクトルDBが「サーバーとして動くもの」だと決まっていたからだ。だが検索対象が手元の数万件で、しかもアプリと同じマシンに置くなら、その1本は本当に必要なのだろうか。
zvec(alibaba/zvec・★15,769・Apache-2.0・2026-09-04時点)は、この前提をSQLiteと同じやり方で崩しにいく。サーバーを立てず、アプリケーションのプロセスの中でベクトル検索を完結させる。Alibaba Group内部で使われてきた実装を切り出したもので、Python・Node.js・Go・Rust・Dartの公式SDKが揃っている。
この記事は機能紹介ではなく、2026-09-04にPython 3.14へ v0.7.0 を入れて実際に動かした結果を軸にする。結論を先に書くと、zvecは想像よりずっと軽く入るが、そのまま使うと索引が作られないまま動き、日本語の全文検索は黙って件数を減らす。どちらもエラーが出ないので、気づかないまま「こんなものか」と判断してしまう類の落とし穴だ。
・サーバー不要。
pip install zvec だけで、依存は numpy 1つ・合計63MB・スキーマにHNSWを書いても索引は作られない。
optimize() を呼ぶまで全件走査のまま動く・日本語の全文検索は既定のトークナイザだと取りこぼす。
ngram の明示指定が要る・読み取り専用なら複数プロセスで同時に開ける。ただし書き手が1つでもいると全員締め出される
ベクトルDBをRAG全体のどこに置くかという地図はRAGとは?仕組み・構築・ベクトルDB選定までの2026年実装マップにまとめてある。この記事はその地図のうち「ストアをサーバーにしない」という選択肢を1つ深掘りするものだ。
zvecとは——サーバーを立てないベクトルデータベース
zvecの位置づけは「軽いQdrant」ではない。アーキテクチャの階層が1つ違う。QdrantやMilvusはネットワーク越しに話しかけるサーバーで、pgvectorはPostgreSQLの拡張だから結局Postgresが要る。zvecはそのどちらでもなく、アプリのプロセスにimportされてそのまま動くライブラリだ。データはローカルのディレクトリに置かれる。
この違いは、運用の手間とレイテンシの両方に効く。ネットワークホップが無いので、クエリのコストにシリアライズとRTTが乗らない。一方で、サーバー型が当たり前に持っている機能——複数ノードへのシャーディング、ネットワーク越しの認証、複数アプリからの同時書き込み——は原理的に持てない。
別プロセス"] --> A3[("ディスク")] end subgraph IP["インプロセス型(zvec)"] direction LR B1["アプリ
import zvec"] -->|"関数呼び出し"| B2["zvec
同一プロセス内"] --> B3[("ローカルの
ディレクトリ")] end
3つの型を並べると、選ぶ基準は「規模」ではなく「誰が書き込むか」に寄っていることがわかる。
| zvec(インプロセス) | Qdrant / Milvus(サーバー) | pgvector(RDB拡張) | |
|---|---|---|---|
| 別プロセスの常駐 | 不要 | 必要 | PostgreSQLが必要 |
| 追加の設置サイズ | 63MB(実測・numpy込み) | コンテナ数百MB〜 | Postgres本体に依存 |
| ネットワークホップ | 無し | 有り | 有り |
| 複数アプリからの書き込み | できない(後述のロック) | できる | できる |
| 水平スケール | 不可 | 可 | 読み取りレプリカ等 |
| 向く規模 | 単一マシンに載る範囲 | 大規模・共有 | 既にPostgresがある構成 |
サーバー型を含めた選定そのものはベクトルデータベース比較2026|Qdrant・Milvus・pgvectorをRAG用途で選ぶ完全ガイドで扱っている。ここでは「インプロセスを選んだ場合に何が起きるか」に絞る。
インデックスの選択肢は7種類あり、メモリに載せるHNSW系からディスクへ退避するDiskANN・Vamanaまで揃っている。量子化を組み合わせるRaBitQ版もあるので、メモリ制約が厳しいときの逃げ道はひととおり用意されている。同じ量子化の考え方をRust実装で追った記事としてturbovecとは|Rust製ベクトル検索インデックスの量子化を実測して解説もある。
インストールと最小構成——依存はnumpyだけ
実際に入れてみると、この手のライブラリとしては異例に軽い。
# Python 3.10〜3.14 が必要。仮想環境を作って入れる
python3 -m venv .venv
./.venv/bin/pip install zvec
# 入ったものを確認する
./.venv/bin/pip show zvec | grep -E "^(Version|Requires|License)"
実測結果は次のとおりだった。Requires: numpy の1行がすべてを物語っている。
| 項目 | 実測値 |
|---|---|
| zvec本体(site-packages) | 29MB(うちネイティブ拡張 _zvec...so が23MB) |
| numpy 2.5.2 | 34MB |
| 合計 | 63MB |
| pipが解決した依存 | numpy のみ |
| PyPI上のwheel | 30個・1個あたり10.6〜15.1MB |
比較対象として、同じzvecファミリーの検索CLIであるzvec-grepとは|ripgrep・BM25・ベクトル検索を1つにまとめるローカル検索CLIを実測はnode_modulesだけで568MBだった。あちらは埋め込みモデルの実行環境(onnxruntime等)を丸ごと同梱するので当然だが、「ベクトルストアそのもの」だけを取り出すとこの軽さになる、という対比は覚えておく価値がある。
索引が実際に食うディスク
インプロセス型では、索引の実体がそのままアプリの配布物やユーザーのディスクに乗る。10,000件×768次元(生のベクトルだけで30.7MB)で作った索引ディレクトリを測ると、optimize() 後で 37.5MB だった。生データに対して約22%の上乗せで、HNSWがノード間のリンクを保持するぶんが乗っている。次元とM値を上げれば当然この比率は増える。
配布面は広く、公式に前ビルド済みバイナリが出ているのは Linux(glibc / musl の両方)・macOS・Windows・Android・iOS。v0.7.0でmusl libc(Alpine Linux)対応が加わり、コンテナイメージを小さく保ちたい構成でも入れられるようになった。同じv0.7.0でmacOS arm64向けの動的ライブラリが37MBから22MBへ約40%削られており、手元で実測した23MBのネイティブ拡張はこの削減後の姿にあたる。
バージョン表記に食い違いがある
細かいが、実際に踏むと厄介な点が2つあった。
1つは対応Pythonバージョンの表記ゆれだ。PyPIのメタデータは requires_python: >=3.9 と宣言しているのに、実際に配布されているwheelのタグは cp310〜cp314 しかない。つまりPython 3.9はpipの依存解決を通過してしまうが、対応するwheelが無いのでソースビルドへ落ち、C++のツールチェーンが無ければそこで失敗する。READMEの「requires 64-bit Python 3.10–3.14」のほうが正しく、パッケージのメタデータのほうが実態から外れている。
もう1つはSDK間のバージョン差だ。この記事の執筆時点で、PyPIのzvecは0.7.0だが、npmの@zvec/zvecは0.7.1(2026-09-04公開)が出ている。言語SDKは同時にリリースされるとは限らないので、複数言語から同じ索引を触る構成では版を揃える確認が要る。
最小のコードはREADMEのとおりで動く。ただし1点だけ、READMEのコメントは結果を {'id': ..., 'score': ...} の辞書だと書いているが、実際に返るのはDocオブジェクトで、d.id / d.score でアクセスする。
import zvec
schema = zvec.CollectionSchema(
name="my_collection", # 短すぎる名前は正規表現で弾かれる
vectors=zvec.VectorSchema(
"emb", zvec.DataType.VECTOR_FP32, 768,
index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP),
),
)
col = zvec.create_and_open(path="./mydb", schema=schema)
col.insert([zvec.Doc(id="doc_1", vectors={"emb": vec})]) # 1回あたり最大1024件
col.flush()
for d in col.query(zvec.Query(field_name="emb", vector=q), topk=10):
print(d.id, d.score) # 辞書ではなく Doc オブジェクト
insert() は1回あたり1024件が上限で、超えると ValueError: Too many docs: 2000 exceeds max write batch size of 1024 になる。数万件を流し込むときは自分でバッチに割る必要がある。コレクション名も正規表現で検証されており、t や fx のような短い名前は name cannot pass the regex verification で弾かれた。
実測1|索引はoptimize()を呼ぶまで作られない
ここがこの記事でいちばん伝えたい部分だ。
最初、10,000件×768次元でベンチマークを取ったところ、zvecのクエリがnumpyの総当たり計算より遅いという結果が出た。HNSWを積んだベクトルDBが素朴な行列積に負けるのはおかしい。そこで数字を疑う前に、索引が本当に存在するのかを確認した。
10,000件を投入して flush() まで済ませたあと col.stats.index_completeness を見ると、返ってきたのは {'emb': 0.0} ——索引が「0%」だった。スキーマで HnswIndexParam を指定していても、insertとflushだけでは索引が作られていない。内部状態を覗くデバッグ関数 _debug_hnsw_storage_mode("emb") も Column 'emb' does not have an HNSW index と例外を投げ、同じことを言う。
optimize() を呼ぶと状態が変わる。
| 状態 | index_completeness |
HNSW索引 | recall@10 |
|---|---|---|---|
insert() + flush() の直後 |
0.0 | 存在しない | 1.000 |
optimize() の後 |
1.0 | mmap |
0.995 |
注目すべきは右端のrecall(再現率)だ。optimize()前のrecallが完全な1.000で、後のほうが0.995へ下がっている。これは異常ではなく、何が起きていたかの決定的な証拠になる。
・optimize() 前は全件を総当たりで比較していた。だから常に正解=recall 1.000で、件数に比例して遅い
・optimize() 後はHNSWによる近似最近傍探索になった。だからごくわずかに取りこぼす代わりに、本来は速い
つまり最初のベンチマークは、HNSWを測っているつもりで総当たり計算を測っていた。この挙動はn=2,000・10,000・20,000の3回とも同じように再現した。
なぜ気づきにくいのか
この挙動が厄介なのは、何も壊れないからだ。索引が無くてもクエリは成功し、しかも近似ではないぶん結果はより正確に返る。例外も警告ログも出ない。手元の1万件程度なら数ミリ秒〜十数ミリ秒で返るので、「まあこんなものか」と受け入れてしまう。データが100万件に増えて初めて、線形に悪化するレイテンシとして表面化する。
対策は単純で、大量投入のあとに必ず optimize() を呼び、index_completeness を確認することに尽きる。
col.insert(docs)
col.flush()
col.optimize() # ← これを忘れない
comp = col.stats.index_completeness
assert all(v >= 1.0 for v in comp.values()), f"索引が未完成: {comp}"
optimize() はただではない。n=2,000で4.7秒かかった。件数が増えれば当然伸びるので、投入のたびに呼ぶのではなく、バッチ投入の最後に1回という設計が要る。
optimize()が重い理由は既定値にある
optimize() に時間がかかるのは、HNSWの既定パラメータが強気に設定されているからでもある。HnswIndexParam の既定は M=50・ef_construction=500。一般的なHNSW実装ではM=16・ef_construction=200あたりが標準的な出発点なので、zvecはかなり精度寄りに振った既定を選んでいることになる。実測でrecall@10が0.995と高かったのはこの設定の裏返しで、索引構築のコストと引き換えに得ている精度だ。構築時間を詰めたいなら、まずこの2つを下げて必要なrecallが出るかを見るのが順序になる。
検索時のパラメータには表記の食い違いが1つある。HnswQueryParam の ef(探索時の候補リスト長)は、関数シグネチャ上の既定値が 300 なのに、docstringの説明文は「Defaults to 100」と書いている。どちらが実際に効くかは実行して確かめていないが、少なくとも説明文の100を鵜呑みにはできない。精度と速度のバランスを調整するときは、この値を明示的に渡したほうが安全だ。
なお本記事ではレイテンシの絶対値は載せていない。計測中のマシンのロードアベレージが33〜40(他の作業と共有していた)で、ミリ秒単位の比較に耐える環境ではなかったためだ。この点は未検証として扱う。一方で index_completeness とrecallは負荷の影響を受けない構造的な値なので、上の結論はそのまま成り立つ。
実測2|日本語の全文検索は既定のトークナイザだと取りこぼす
zvecはベクトル検索だけでなく全文検索(FTS)も持っていて、両者を1クエリで混ぜるハイブリッド検索ができる。日本語で使う前提だと、ここに設定上の分岐がある。
まずトークナイザの一覧を実測で確定させた。存在しない名前を渡すと弾かれるので、でたらめな文字列を対照群にして「エラーが本物の検証か」を確かめている。
指定したtokenizer_name |
結果 |
|---|---|
standard(既定) |
使える |
ngram |
使える(v0.7.0で追加) |
jieba |
使える(中国語向け) |
whitespace |
使える |
kuromoji |
unknown tokenizer name |
mecab |
unknown tokenizer name |
lindera |
unknown tokenizer name |
ZZZ_not_a_tokenizer(対照群) |
unknown tokenizer name |
日本語向けの代表的な形態素解析器3つが、でたらめな文字列とまったく同じエラーで弾かれた。つまり日本語専用のトークナイザは用意されていない。中国語にはjiebaという専用の選択肢があるのと対照的だ。
既定のstandardは何を取りこぼすか
では既定のstandardで日本語がどうなるのか。2文書だけの最小構成で確かめた。
・d1 =「ベクトルデータベースを使った検索」
・d3 =「ベクトル検索を行う」
| クエリ | standard(既定) |
ngram |
|---|---|---|
| ベクトル | d3 のみ | d3, d1 |
| ベクトルデータベース | d1 | d1, d3 |
| データベース | 0件 | d1 |
| 検索 | d3, d1 | d3, d1 |
standardは「データベース」で1件も返さない。d1は文字列としては明らかに「データベース」を含んでいるのに、である。
理由は、standardが日本語を文字種の切れ目で区切っているからだ。d1の「ベクトルデータベース」はカタカナが連続しているので、これで1つの語になる。クエリの「データベース」はその語の後ろ半分でしかないため、語として一致しない。同じ理屈で「ベクトル」もd1では引っかからない(d3では「ベクトル」+「検索」と文字種が変わるので、独立した語になり一致する)。漢字の「検索」が両方でヒットするのも同じ説明がつく。
ngramはテキストを文字N-gram(既定は2文字)に刻むので、語の切れ目を推定しない。結果として日本語では期待どおりの部分一致になる。
日本語のコーパスを入れるなら、スキーマの時点でこう書く。
schema = zvec.CollectionSchema(
name="ja_docs",
fields=zvec.FieldSchema(
"body", zvec.DataType.STRING,
index_param=zvec.FtsIndexParam(tokenizer_name="ngram"), # 既定のstandardは使わない
),
vectors=zvec.VectorSchema("emb", zvec.DataType.VECTOR_FP32, 768),
)
なおjiebaでも日本語はある程度ヒットするが、中国語の辞書で日本語を切るため、本来含まれない文書まで返す(5文書のテストで「データベース」に3件が返った)。日本語ではngramが妥当な既定と考えてよい。N-gramは語の意味を見ないぶん索引が大きくなりやすいので、コーパスが大きい場合はサイズを測ってから決めるとよい。
ハイブリッド検索は「両方が支持する文書」を押し上げる
zvecが掲げる機能のうち実務で効くのは、ベクトル検索とFTSを1回のクエリで融合できることだ。3文書の最小構成で挙動を確かめた。ベクトル側はd1に最も近い向きを、FTS側は「全文検索」を引いている。
| 検索方法 | 1位 | 2位 | 3位 |
|---|---|---|---|
| ベクトルのみ | d1(1.000) | d3(0.700) | d2(0.000) |
| 全文検索のみ | d3(1.575) | d2(1.470) | (d1はヒットせず) |
| ハイブリッド(RRF) | d3(0.0325) | d2(0.0320) | d1(0.0164) |
| ハイブリッド(加重 0.5:0.5) | d3(0.667) | d2(0.560) | d1(0.375) |
ベクトルだけなら1位だったd1が、融合すると最下位に落ちている。代わりに上がったd3は「ベクトル検索と全文検索を組み合わせる」という、両方の信号がそろって支持する文書だ。片方の指標だけで上位に来る文書を、もう片方が押し下げる——これがハイブリッドに期待する挙動そのものである。融合方式は順位ベースのRRF(RrfReRanker)と、スコアを重み付けする WeightedReRanker が選べる。
1つ制約がある。構造化フィルタ(filter= 引数)にFTS対象のフィールドは使えない。body LIKE '%全文%' のような条件を渡すと fts field is not allowed in filter condition で拒否される。全文検索対象のカラムは検索経路でのみ扱い、絞り込み条件には別のフィールドを用意する設計が要る。
実測3|「インプロセス」の実際——通信・スレッド・プロセス間ロック
「サーバーを立てない」「ローカル完結」は、この種のツールで最もよく掲げられ、最も検証されない主張だ。実際にどうなのかを測った。
外部通信
Pythonのsocket.connectを差し替えて、zvecの一連の処理(作成・投入・flush・optimize・クエリ)が接続を試みるかどうかを記録した。重要なのは傍受が本当に効いていることを先に確かめることなので、既知の外部ホストへ接続する陽性対照を同じスクリプトの中で走らせている。
具体的には socket.socket.connect を記録用の関数で包み、zvecの処理中に呼ばれたアドレスを集めた。そのうえで同じスクリプトの末尾で example.com へ接続し、傍受そのものが機能していることを確認してから結果を読んでいる。陽性対照を置かない計測は「0件」と「そもそも記録されていない」を区別できない。
結果は次のとおり。
| 観測項目 | 結果 |
|---|---|
zvecの全処理中のPython層 connect() |
0件 |
| 陽性対照(example.com への接続) | 4件記録(傍受が効いている証拠) |
プロセスのネットワークソケット(lsof -a -p PID -i) |
無し |
| スレッド数 | 1 |
| 子プロセス | 0個 |
サーバープロセスもワーカースレッドも増えず、ソケットも開かない。この方法で観測できた範囲では通信は0件だった。ただし限界も書いておくと、Python層の差し替えで捕まえられるのはPythonのsocketモジュール経由の接続だけで、C++実装が直接OSのconnect(2)を呼んだ場合はlsofのスナップショットに映る瞬間しか捉えられない。「通信ゼロを証明した」ではなく「観測した範囲では0件だった」が正確な表現になる。
プロセス間ロック
READMEは「複数プロセスが同時に読める。書き込みは単一プロセス排他」と書いている。これを組み合わせごとに実測した。
| 先に開いているプロセス | 後から開くプロセス | 結果 |
|---|---|---|
| なし | read_only=True ×3 |
3つとも成功 |
read_only=True |
read_only=True ×2 |
2つとも成功 |
read_only=True |
書き込み | 失敗(即エラー) |
| 書き込み(read-write) | read_only=True |
失敗(即エラー) |
| 書き込み(read-write) | 書き込み | 失敗(即エラー) |
READMEの記述自体は正しい。ただし実際に使うときに効いてくる性質が2つある。
1つめは、ロックが順番待ちをしないこと。空くまでブロックするのではなく Can't lock read-write collection: ./path/LOCK を即座に投げる。複数プロセス構成では、呼び出し側でリトライを書く前提になる。
2つめが実務上いちばん引っかかる点で、zvec.open() の既定は read-write だということ。読むだけのつもりで素直に zvec.open(path) と書くと、そのプロセスが書き込みロックを取り、他のすべてのプロセスを締め出す。読み手は必ず明示的に指定する。
読み手は zvec.open("./mydb", zvec.CollectionOption(read_only=True)) と必ず明示する。
この検証中、書き込みプロセス2つを同時に走らせて両方成功する回が一度あった。ロックが効いていないように見えたが、再実行すると片方が正しくブロックされた。実行タイミングが偶然ずれて、1つめが閉じたあとに2つめが開いていただけだった。タイミング依存の検証は1回の結果で判断できないという、ごく普通の教訓でもある。
zvec-grep・Zvec Studio との関係
「zvec」で検索すると複数のリポジトリが出てくるので、関係を整理しておく。まず押さえるべきは、alibaba/zvec と zvec-ai/* は別のGitHub organizationだが、地続きの1つのプロダクトだということだ(zvec-ai orgの公式サイトはalibaba/zvecと同じzvec.orgを指している)。
| プロジェクト | 正体 | 使う場面 |
|---|---|---|
alibaba/zvec(本記事) |
C++実装のベクトルDB本体 | 自分のアプリにベクトル検索を組み込む |
zvec-ai/zvec-grep |
zvecを使った検索CLI(zg) |
手元のコード・文書をコマンドで探す |
zvec-ai/zvec-studio |
GUIの管理ツール | 索引の中身を目で見る・クエリを試す |
zvec-ai/zvec-node ほか |
各言語のバインディング | Python以外から使う |
zvec-grepが本体に依存していることは推測ではなく確認できる。@zvec/zvec-grep 0.2.1 の package.json は依存に "@zvec/zvec": "^0.7.0" を持っている。zvec-grepはzvecの利用者であって、別物でも上位互換でもない。
使い分けは単純だ。自分でスキーマを設計してアプリに埋め込みたいならzvec、すでにあるディレクトリを今すぐ検索したいならzvec-grepである。後者を検討しているなら、日本語の文書120本を索引して所要時間やMCP経由の常駐コストまで測ったzvec-grepの実測記事のほうが目的に合う。
zvecが向く場面と向かない場面
ここまでの実測を、判断に使える形にまとめる。
| 使い方 | 判定 | 理由 |
|---|---|---|
| 単一アプリに検索機能を埋め込む | ◎ | サーバー不要・63MB・通信も観測範囲で0件 |
| デスクトップアプリ・CLIに同梱する | ◎ | 5言語のSDKとFlutter対応まである |
| ノートブックでの試行・プロトタイプ | ◎ | pip install だけで始まる |
| 日本語ドキュメントの全文検索 | ○ | 動くが ngram の明示指定が必須 |
| 複数のアプリが同じ索引に書き込む | ✗ | 書き込みは単一プロセス排他。設計上できない |
| 複数ノードへスケールさせたい | ✗ | インプロセス型の対象外。サーバー型を選ぶ |
| 数千件規模でとにかく速くしたい | △ | その規模ならnumpyの総当たりでも十分速い |
最後の行は補足が要る。数千〜1万件規模では、768次元のベクトルを素朴に行列積で総当たりしても実用的な速度が出る。zvecの価値は「小さいデータを速くする」ことではなく、データが増えたときに索引へ移行できる余地を最初から持っておくことと、それをサーバーの運用コストなしで得られることにある。逆に言えば、いま数千件で、この先も増えないなら、依存を1つ増やす理由は薄い。
そして繰り返しになるが、その「索引へ移行できる余地」はoptimize()を呼んで初めて現実になる。スキーマにHNSWと書いただけでは、zvecは最後まで総当たりで正しい答えを返し続ける。
参照ソース
・alibaba/zvec — GitHubリポジトリ(README・リリースノート v0.7.0 を参照。★・バージョンは2026-09-04時点)
・Zvec 公式ドキュメント(Quickstart・インデックス種別・ベンチマーク方法論)
・zvec — PyPI(wheelのPythonタグ・requires_python・配布サイズの確認元)
・zvec-ai/zvec-grep — GitHubリポジトリ(package.json の依存関係から本体との関係を確認)
・本記事の実測値は macOS 14.5 / Apple M2 MacBook Air 16GB / Python 3.14.4 / zvec 0.7.0 で2026-09-04に取得。レイテンシの絶対値はマシン負荷が高く(ロードアベレージ33〜40)信頼できる測定ができなかったため掲載していない