07/18 09:34 2026
07/18 09:53 2026
Self-Managed(セルフホスト)のTiDBにTiFlashを追加し、ベクトル検索による日本語セマンティック検索を構築して、どこまで実用になるかを検証します。埋め込みモデルには、以前の記事で紹介したPLaMo-Embedding-1Bを採用し、こちらもGPUなしのCPU推論でセルフホストします。
この構成の最大のメリットは、PostgreSQLにおけるpgvectorと同様に、検索専用のミドルウェアを別に立てず、アプリケーションDB内で検索が完結することです。
実測の数字と結論だけを読みたい場合は、まとめへどうぞ。
全体構成を示します。本記事で扱うのは右側のKubernetes部分のみで、左側はそれを利用するWebシステムです。

左側のWebシステムは、個人運用で料金を抑えることを最優先にしているため、一般的な設計とは異なる変則的な構成です。あくまで検証の背景として見てください。Kubernetesクラスタを含むベース環境の詳しい構成・構築方法は、次のリポジトリを参照してください。
本構成で利用しているTailscaleのPersonalプランは、個人による非商用利用が対象です。本記事のWebシステムは、筆者が個人で非商用運営しているサービスであり、この前提でPersonalプランを利用しています。業務・商用目的で同様の構成を採用する場合はPersonalプランの対象外となるため、用途に合った有料プランを検討してください。最新の適用条件はTailscaleの料金ページを確認してください。
検証環境は、ミニPC3台のKubernetesクラスタ上でTiDB Operatorにより運用しているTiDB v8.5.7(PD / TiKV / TiDB構成)です。GPUはありません。
| ノード | CPU | メモリ | 本記事での役割 |
|---|---|---|---|
| node1 | Ryzen 7 7730U (8コア/16スレッド) | 32GB | TiFlash |
| node2 | Ryzen 7 7730U (8コア/16スレッド) | 32GB | PLaMo推論サービス |
| node3 | Ryzen 7 7730U (8コア/16スレッド) | 32GB | PLaMo推論サービス |
埋め込み生成のCPU推論とベクトルインデックスのビルドは、どちらもCPU負荷の高いワークロードです。リクエストだけに基づくKubernetesのスケジューラ任せにすると同じノードに同居し得るため、この配置はnodeAffinityで明示的に固定しています(詳細はPLaMoの章で説明します)。
クラスタへはTailscale経由で到達し、TiDBにはMagicDNS名(tidb.<tailnet>)で接続します。以降のコマンドは次の環境変数を前提にします。
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
TiDBの検索機能は提供形態ごとに対応が分かれています。
| 機能 | Cloud Starter / Essential | Cloud Dedicated | Self-Managed | TiFlash |
|---|---|---|---|---|
| FULLTEXT INDEX | ○ (Preview / 一部 AWS リージョン) | ✕ (構文パースのみ) | ✕ (構文パースのみ) | 必須 |
| VECTOR 型 | ○ | ○ (v8.4.0 +) | ○ (v8.4.0 +) | 不要 |
| VECTOR + HNSW1 | ○ | ○ (v8.4.0 +) | ○ (v8.4.0 +、v8.5+ 推奨) | 必須 |
VECTOR型は、格納と距離関数(VEC_COSINE_DISTANCEなど)だけならTiKVのみで動きますが、インデックスがないため距離計算は全件スキャンになります。HNSWインデックスを張る場合はTiFlashが必須です。
本記事で使うクラスタはSelf-Managed v8.5.7です。FULLTEXT INDEXは構文としてパースされるだけでインデックスは作られず、fts_match_wordも動きません。VECTOR + HNSWは動きます。検索をTiDB内で完結させるなら、ベクトル検索が現実的な選択肢になります。
なお、TiDBの全文検索は現在ベータ版です。今後の改善予定として、CJK(中日韓)言語に対する検索結果の改善と並んで「TiDB Self-Managedクラスタへの対応」が明記されています🔥
同じMySQL互換でも、本家MySQLは状況が逆です。
| 機能 | MySQL | 備考 |
|---|---|---|
| FULLTEXT INDEX | ○ | ngram パーサで日本語にも対応 |
| VECTOR 型 | ○ (9.0 +) | 格納用のデータ型のみ |
| VECTOR + HNSW | ✕ | ANN インデックスは未対応 |
全文検索は以前からできる一方、ベクトルは格納できるだけです。前節の通りTiDBはTiKVのみでも距離関数による全件スキャンの類似検索は書けますが、MySQLは距離関数自体がHeatWave2限定のため、SQLでは類似検索そのものが書けません。ベクトル検索基盤として採用するのは厳しい状況です。
検討した候補を本構成の要件で整理すると次の通りです。MySQL互換のDBを探しているという前提に寄った比較ですが、その前提を素直に書き出した結果として見てください。
| 本構成の要件 | TiDB | MySQL | pgvector | OpenSearch |
|---|---|---|---|---|
| ANN (HNSW) が使える | ○ | ✕ | ○ | ○ |
| 検索データの同期パイプラインが不要 | ○ | ○ | ○ | ✕ |
| MySQL 互換(既存資産をそのまま使える) | ○ | ○ | - | - |
| 公開状態・タグ等の絞り込みを検索と同一クエリで書ける | ○ | ○ | ○ | ✕ |
| 2048次元をそのままインデックス化できる | ○ | ✕ | ○3 | ○ |
補足が必要な行だけ触れておきます。
この環境にベクトルインデックスの実行基盤となるTiFlashを追加し、検証用のデータベースとチャンク4テーブルを作ります。検証データには日本語Wikipediaを使います。
TidbClusterのマニフェストにtiflashセクションを追記して適用します。
tiflash: baseImage: pingcap/tiflash replicas: 1 requests: cpu: '500m' memory: '4Gi' limits: memory: '8Gi' storageClaims: - resources: requests: storage: 50Gi storageClassName: local-path config: config: | [logger] level = "info" [profiles.default] max_memory_usage = 0 proxy: | log-level = "info" topologySpreadConstraints: - topologyKey: kubernetes.io/hostname maxSkew: 1
TiFlash特有の注意点として、spec.tiflash.configはTiFlash本体のconfigと、組み込みTiKV Learnerのproxyの2つのサブキーを持ちます(pd / tikv / tidbのconfig: |単一キーとは書式が異なります)。またstorageClaimsはリストで、データディレクトリを複数PVに分散できる設計です。
kubectl -n tidb-cluster apply -f tidb-cluster.yaml
kubectl -n tidb-cluster get pods -l app.kubernetes.io/component=tiflash -w
# basic-tiflash-0 が 4/4 Running になるまで待つ (5〜15分)
PDがTiFlashをストアとして認識していることを確認します。
kubectl -n tidb-cluster exec -it basic-pd-0 -- /pd-ctl store
# labels に engine=tiflash の store が1つ見えればOK
検証データは既存のアプリケーションのDBと混ぜず、専用のデータベースに入れます。分離しておくことで、検証が終わったらDROP DATABASE一発で片付けられます。
mysql -h tidb.${TAILNET} -P 4000 -u root -e "CREATE DATABASE bench_wiki;"
Wikipediaのページをチャンク単位で格納するテーブルを作ります。ポイントはembedding VECTOR(2048)列と、テーブル単位でTiFlashレプリカを張るSET TIFLASH REPLICAです。
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki <<'SQL'
CREATE TABLE wiki_embedding_chunks (
chunk_id CHAR(36) NOT NULL DEFAULT (UUID()),
page_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(512) NOT NULL,
chunk_index INT UNSIGNED NOT NULL,
content LONGTEXT NOT NULL,
token_count INT UNSIGNED NOT NULL,
embedding VECTOR(2048) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (chunk_id),
UNIQUE KEY uq_wiki_embedding_chunks_page_index (page_id, chunk_index)
);
ALTER TABLE wiki_embedding_chunks SET TIFLASH REPLICA 1;
SQL
embedding VECTOR(2048): 採用モデルの出力次元に合わせます。NOT NULLにしている理由はインデックス化の章で説明しますpage_id / title: WikipediaダンプのページIDとタイトルです。検索結果を目視確認するときのためにタイトルも持たせますレプリカの同期状況はINFORMATION_SCHEMAで確認できます。
mysql -h tidb.${TAILNET} -P 4000 -u root -e "
SELECT TABLE_SCHEMA, TABLE_NAME, REPLICA_COUNT, AVAILABLE, PROGRESS
FROM INFORMATION_SCHEMA.TIFLASH_REPLICA
WHERE TABLE_NAME = 'wiki_embedding_chunks';"
# AVAILABLE = 1, PROGRESS = 1 になれば同期完了
HNSWインデックスはこの時点では作りません。データの投入が終わった後に作成します。順序を誤るとTiFlashがクラッシュする罠があり、これもインデックス化の章で説明します。
埋め込みモデルには、Preferred NetworksがApache License 2.0で公開している日本語特化のPLaMo-Embedding-1Bを採用します。出力は2048次元で、日本語テキスト埋め込みベンチマークのJMTEBで公開時点トップクラスのスコアを記録しているモデルです。GPUなしのCPUで推論できるため、ミニPCクラスタでのセルフホストと相性が良いのが採用理由です。外部の埋め込みAPIに依存しないので、課金もデータの外部送信もありません。
同じPLaMo-Embedding-1BはCloudflare Workers AI(@cf/pfnet/plamo-embedding-1b、$0.019/100万トークン)でも提供されています。1万チャンク(実測の合計は約824万トークン)の埋め込み生成で比べると次の通りです。それでもセルフホストする理由はまとめで扱います。
| 本構成(CPU推論) | Workers AI | |
|---|---|---|
| 所要時間 | 約10時間 | 分〜数十分 |
| 料金 | 電気代のみ | 約$0.16(約24円) |
PLaMo-Embedding-1Bはsentence-transformers形式ではなく、AutoModelからencode_query / encode_documentを呼ぶカスタム実装です。そのままではHTTPで叩けないため、FastAPIで薄いラッパーを書きます。モデルはPod起動時に1回だけロードして常駐させます。
MODEL_ID = os.environ.get("MODEL_ID", "pfnet/plamo-embedding-1b") DEVICE = "cuda" if torch.cuda.is_available() else "cpu" tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True) model = AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True).to(DEVICE).eval() @app.post("/embed", response_model=EmbedResponse) def embed(req: EmbedRequest) -> EmbedResponse: if req.mode not in ("query", "document"): raise HTTPException(status_code=400, detail="mode must be 'query' or 'document'") with torch.inference_mode(): if req.mode == "query": vec = model.encode_query(req.text, tokenizer) else: vec = model.encode_document(req.text, tokenizer) vec_list = vec.squeeze(0).cpu().float().tolist() return EmbedResponse(vector=vec_list, dim=len(vec_list))
検索クエリと格納ドキュメントでエンコード用APIが分かれている点(非対称埋め込み)が重要です。検索時のクエリはencode_query、インデックス時の本文はencode_documentで埋め込みます。
モデルファイル(約2.3GB)はDockerイメージのビルド時にダウンロードし、イメージに含めます。Pod起動時のダウンロードをなくし、Hugging Faceへのネットワーク依存をビルド時に閉じ込めるためです。
# syntax=docker/dockerfile:1 # CPU 版 PyTorch を linux/amd64 向けにインストール (MiniPC cluster は Ryzen 7 7730U)。 # amd64 wheel は https://download.pytorch.org/whl/cpu に登録済み。 FROM python:3.12-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app RUN pip install --no-cache-dir \ --index-url https://download.pytorch.org/whl/cpu \ torch==2.5.1 \ && pip install --no-cache-dir \ transformers==4.46.0 \ sentencepiece==0.2.0 \ fastapi==0.115.4 \ uvicorn==0.32.0 \ pydantic==2.9.2 COPY server.py chunking.py /app/ # ビルド時にモデルファイルを事前ダウンロードし image に焼き込む (~2.3GB)。 # AutoModel.from_pretrained() を使うと RAM に model を全展開して verify する # ため Docker Desktop 既定メモリ (2-4GB) を超えて OOM になる。snapshot_download は # ファイル取得だけで RAM を使わない (transformers の transitive dep なので追加 # install 不要)。runtime の server.py は HF cache から load するので network 不要。 ARG MODEL_ID=pfnet/plamo-embedding-1b ENV MODEL_ID=${MODEL_ID} RUN python -c "from huggingface_hub import snapshot_download; \ snapshot_download(repo_id='${MODEL_ID}')" EXPOSE 8080 CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]
モデルを組み込む理由とsnapshot_downloadを使う理由は、コメントの通りです。ハマりどころが1つあり、Buildxがデフォルトで付けるattestation manifestを古いバージョンのcontainerdが解釈できず、no match for platform in manifestでプルに失敗することがあります。--provenance=false --sbom=falseを付けて単一プラットフォームのマニフェストにすると回避できます。
イメージはGitHub Container Registry(GHCR、ghcr.io)に置きます。GHCRのdocker loginはGitHubのWebパスワードを受け付けず、PAT(Personal Access Token)が必須です。gh CLIを使っている場合は、既存の認証にpackagesスコープを追加してトークンを流用するのが最短です。
gh auth refresh --scopes write:packages,read:packages
gh auth token | docker login ghcr.io -u <your-account> --password-stdin
# → "Login Succeeded" が出ればOK
ビルドとプッシュはBuildxで行います。ターゲットノードはamd64なので、Apple Silicon Macから実行する場合はQEMUエミュレーションを挟んだクロスビルドになります(PyTorchのインストールが重く、初回は10〜20分かかります)。
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--sbom=false \
-t ghcr.io/<your-account>/plamo-embedding:latest \
--push \
.
プッシュ後の注意として、GHCRのパッケージはリポジトリの公開設定にかかわらず、必ずprivateで作られます。privateのままだとkubeletがプルできずErrImagePullになるため、imagePullSecretsを設定するか、公開して問題ないイメージであればパッケージをpublicへ切り替えます。個人アカウントのパッケージの可視性はREST APIから変更できず、Web UI(パッケージのSettings)からのみ操作できます。
apiVersion: v1 kind: Namespace metadata: name: plamo-embedding --- apiVersion: apps/v1 kind: Deployment metadata: name: plamo-embedding namespace: plamo-embedding spec: # node1 は TiFlash の vector index build 用に空け、node2 / node3 へ 1 Pod ずつ配置する。 replicas: 2 # maxSurge=0 で更新中にも追加 Pod を作らず、モデル分のメモリ増加を防ぐ。 strategy: type: RollingUpdate rollingUpdate: maxSurge: 0 maxUnavailable: 1 selector: matchLabels: app: plamo-embedding template: metadata: labels: app: plamo-embedding spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - node2 - node3 podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: plamo-embedding topologyKey: kubernetes.io/hostname containers: - name: server image: ghcr.io/<your-account>/plamo-embedding:latest imagePullPolicy: Always ports: - containerPort: 8080 readinessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 timeoutSeconds: 3 startupProbe: httpGet: path: /healthz port: 8080 # node ごとの初回 image pull と model load を最大 10 分待つ。 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 60 livenessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 resources: requests: cpu: '500m' memory: '4Gi' limits: # 常駐約5.5GiB + 同時リクエスト時のアクティベーションで6Giでは # OOMKillされたため8Gi(詳細はインデックス化の章) memory: '8Gi' --- apiVersion: v1 kind: Service metadata: name: plamo-embedding namespace: plamo-embedding spec: type: ClusterIP selector: app: plamo-embedding ports: - port: 80 targetPort: 8080
配置の意図(TiFlash用にnode1を空ける、maxSurge: 0、モデルロードを見込んだstartupProbe)は、マニフェスト内のコメントの通りです。
resources.requestsがcpu: 500mと実際の推論負荷(8コア、後述)より大幅に小さいのは意図的です。実態に合わせてcpu: 8を要求するとノードの半分を常時予約する硬直した配置になるため、requestsは小さく保ち、代わりにnodeAffinity / podAntiAffinityで配置を固定しています。スケジューラはrequestsしか見ないので、この縛りがないとTiFlashのいるnode1へPLaMoが同居し得ます。
ServiceはTailscale Kubernetes Operator経由でplamo-embedding.<tailnet>として公開し、動作確認します。
curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"日本語の検索テスト","mode":"query"}' | jq '.dim'
# → 2048
常駐メモリはアイドル状態(リクエストなし、CPUほぼゼロ)でPodあたり約5.5〜5.7GiBでした。モデルをロードしたプロセスがこのサイズで常駐するため、メモリ上限までの余裕は大きくありません。deployment.yamlのmaxSurge: 0で更新中に追加Podを作らないようにしているのは、ノード上にもう1つ常駐させる余裕がないためです。なお、当初の上限は6Giとしていましたが、これは同時1リクエストを前提とした値で、後の投入中にOOMKilledとなり8Giへ引き上げることになります(インデックス化の章で後述)。
GrafanaのPod一覧。plamo-embeddingの2 Podがメモリ使用量の上位2つを占め、アイドル時でも5.5GiB前後を常時使用している
for p in $(kubectl -n plamo-embedding get pods -o name); do
echo "== $p"
# uvicornプロセス(PID 1)のRSS = モデル常駐分の実メモリ
kubectl -n plamo-embedding exec "$p" -- grep VmRSS /proc/1/status
# コンテナcgroup全体のメモリ使用量
kubectl -n plamo-embedding exec "$p" -- cat /sys/fs/cgroup/memory.current \
| awk '{printf "cgroup memory.current: %.2f GiB\n", $1/1073741824}'
done
== pod/plamo-embedding-7f46d8c85f-5vfm7
VmRSS: 5795120 kB
cgroup memory.current: 5.45 GiB
== pod/plamo-embedding-7f46d8c85f-r6pwv
VmRSS: 6083584 kB
cgroup memory.current: 5.72 GiB
エンコード時間は次の方法で計測しました。
/chunksで上限いっぱい(1,023トークン)のチャンクを1件生成し、mode: documentのリクエストJSONを作るtime_total(E2E)。ローカルネットワークのRTTは数msなので誤差範囲ですリクエストJSONの生成です。
import json, urllib.request
TAILNET = "<tailnet>"
text = "日本語のベンチマーク用テキストです。埋め込みモデルの推論時間を計測しています。" * 400
body = json.dumps({"title": "bench", "description": "", "content": text,
"max_tokens": 1024, "overlap_tokens": 128}).encode()
req = urllib.request.Request(f"http://plamo-embedding.{TAILNET}/chunks",
data=body, headers={"Content-Type": "application/json"})
resp = json.load(urllib.request.urlopen(req, timeout=120))
chunk = resp["chunks"][0]
print("token_count:", chunk["token_count"]) # → 1023
with open("embed-req.json", "w") as f:
json.dump({"text": chunk["embedding_text"], "mode": "document"}, f, ensure_ascii=False)
計測ループです。
# ウォームアップ3回
for i in 1 2 3; do
curl -s -o /dev/null -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' -d @embed-req.json
done
# 本計測20回 → 分布を集計
for i in $(seq 1 20); do
curl -s -o /dev/null -w '%{time_total}\n' \
-X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' -d @embed-req.json
done | sort -n | awk '{a[NR]=$1} END {
printf "min: %.3fs\np50: %.3fs\np95: %.3fs\nmax: %.3fs\n", a[1], a[int(NR*0.5)], a[int(NR*0.95)], a[NR]}'
結果は次の通りです。
| 指標 | 時間 |
|---|---|
| min | 6.88秒 |
| p50 | 6.97秒 |
| p95 | 7.04秒 |
| max | 7.06秒 |
1チャンク(約1,000トークン)のエンコードに約7秒かかります。ばらつきが3%未満と極めて安定しているのは、CPU推論らしい挙動です。エンコード時間は入力トークン数にほぼ比例するため、検索クエリのような短文は1秒未満で返る一方、1,000トークン級のドキュメントチャンクはこの重さになります。つまり、CPU推論の実用性は「クエリ側(検索)は問題ないが、ドキュメント側(インデックス化)は時間との勝負」という非対称な結論になります。
ベンチマーク中のノードCPU使用率は40%前後までしか上がりません。それでも並列リクエストを投げたときのスループットはほとんど伸びず、実測上限はPodあたり約1/7 req/s、同時4(Podあたり2)の合計で0.26 req/sでした。次章のデータ投入は、この値を前提に同時4で実行します。「CPUが余っているのになぜ伸びないのか」の深掘りは付録に回します。
逐次ベンチマーク中のノードCPU。リクエストは2 Podへ接続単位で分散するため、各ノードのCPU使用率は最大でも40%前後にとどまる
検証コーパスには日本語WikipediaのCirrusSearchダンプを使います。通常のXMLダンプと違い、wikitext(マークアップ)を除去済みのプレーンテキストがtextフィールドに入っているため、前処理がほぼ不要です。
なお配布場所はother/cirrussearch/(週次の単一巨大ファイル)からother/cirrus_search_index/(約640MBのシャード分割)へ移行しています。日本語Wikipedia本文(jawiki_content)は全14シャード・合計約9GBですが、1万チャンクの投入なら1シャードで十分です。ダウンロードは実行環境となるnode1上で直接行います(手順は後述)。
中身はNDJSONで、{"index": {"_id": ...}}の行と、page_id / title / textを持つドキュメント行が2行1組で並んでいます。使うのはドキュメント行だけです。
ブログ記事と違い、WikipediaはMarkdownの見出し構造を持たないため、PLaMoトークナイザによる固定長ウィンドウ(1,024トークン、128トークンのオーバーラップ)で分割します。各チャンクの先頭にはページタイトルを付与してから、mode=documentで埋め込みます。投入スクリプトの流れは次の通りです。
page_id / title / textを取り出す(200文字未満のスタブは除外)POST /chunksでトークナイズして固定長チャンクに分割するPOST /embed(mode=document)で2048次元ベクトルを得る(並列度は負荷特性の実測に基づく同時4)wiki_embedding_chunksへINSERTする(embeddingは'[0.02,-0.11,...]'形式の文字列リテラル)この流れをPythonスクリプトingest_wiki.pyに実装しています。11時間級の長時間実行になるため、途中で停止しても投入済みページをスキップして再開できる作りにしておくことが重要です。INSERTをページ単位のトランザクションにし、起動時に投入済みのpage_idを読み飛ばすようにしています。
実行環境はnode1です。約11時間の長時間実行になるため、手元のPCではなく常時稼働しているクラスタノードへSSH接続し、nohupでバックグラウンド実行します(手元でtmuxを使っているとリモートのtmuxとネストして操作が複雑になるため、リモート側では使いません)。node1を選ぶのは、PLaMoのPodが載っておらず、投入処理が推論のCPUと競合しないためです。node1自体もtailnetに参加しているので、接続先の組み立てにはこれまでと同じTAILNETを使えます。
# node1に作業ディレクトリを作り、スクリプトを送る
ssh node1 'mkdir -p ~/work/20260717'
scp ingest_wiki.py node1:~/work/20260717/
ssh node1
cd ~/work/20260717
# uvの初回インストール(依存のpymysqlはPEP 723メタデータからuv runが解決する)
curl -LsSf https://astral.sh/uv/install.sh | sh && source ~/.local/bin/env
# シャード(約640MB)を作業ディレクトリへ直接ダウンロード(日付は取得時点の最新に読み替え)
curl -LO "https://dumps.wikimedia.org/other/cirrus_search_index/20260712/index_name%3Djawiki_content/jawiki_content-20260712-00000.json.bz2"
# 接続先(PLaMoサービスとTiDB)はTAILNETから組み立てる
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
# まず小さく動作確認
uv run ingest_wiki.py jawiki_content-20260712-00000.json.bz2 --target-chunks 100
# 本実行(1万チャンク、約11時間)。nohupでバックグラウンドに投げたらsshを切ってよい
nohup uv run ingest_wiki.py jawiki_content-20260712-00000.json.bz2 --target-chunks 10000 \
> ingest-10k.log 2>&1 &
# embed / INSERTなしでチャンク数の見積もりだけ行う場合
uv run ingest_wiki.py jawiki_content-20260712-00000.json.bz2 --dry-run
進捗はバッチ(8チャンク、約30秒)ごとにログへ出ます。投入済み件数、実効スループット、残りのETAが確認でき、中断しても再実行すれば投入済みページをスキップして続きから再開します。この再開ロジックがあるため、動作確認で入れた分の削除は不要で、本実行がそのまま引き継いで1万件の一部になります。チャンク分割のパラメータを変えて入れ直す場合だけ、TRUNCATE TABLE wiki_embedding_chunksでリセットしてください。
[21:15:32] 248/10000 chunks (102 pages, 0.26 chunks/s, ETA 10.4h)
nohupで実行した後は、そのままSSH接続を切断できます。途中経過は手元から次のように確認できます。
ssh node1 'tail -3 ~/work/20260717/ingest-10k.log'
スループットの支配項は埋め込み生成です。前章の実測(1チャンク約7秒、同時4で0.26チャンク/s)から、1日あたり約2.2万チャンクが上限になります。1万チャンクで約11時間、10万チャンクで約4.5日という規模感で、100万チャンク分の実埋め込み生成(約45日)は現実的ではありません。CPU推論のセルフホストは、検索クエリの埋め込みには十分でも、大規模コーパスの一括投入では明確なボトルネックになります。本記事ではまず1万チャンクを投入して検索性能を計測します。
本実行を開始して約1分、ログにHTTPリトライが並びました。
[04:05:58] HTTP retry 1/3 http://plamo-embedding.<tailnet>/embed: Remote end closed connection without response
Podを確認すると、node3側が終了コード137(OOMKilled)で再起動していました。原因は同時リクエストです。常駐約5.5GiBのところへ、同時に処理する推論のアクティベーション(中間バッファ)が積み上がり、当初のlimits.memory: 6Giを超えました。実際、上限引き上げ後に計測した投入中のPodメモリは約6.1GiBで、変更前の上限をまさに超える値です。負荷特性の章で触れた「わずかなヘッドルーム」は、同時1リクエスト前提の値だったわけです。
対処はメモリ上限を8Giへ引き上げるだけです。maxSurge: 0 / maxUnavailable: 1のローリング更新なので投入ジョブを止める必要はなく、切り替え中の失敗はスクリプトのリトライが吸収し、ページ単位トランザクションのおかげでデータの不整合もありませんでした(PLaMoの章に掲載しているマニフェストは修正済みの8Giです)。
教訓は「アイドル時の実測だけでメモリ上限を決めない」です。同時実行ではCPUだけでなく、リクエスト数に比例してアクティベーション分のメモリも必要になります。
本実行は序盤にOOMKillが発生したものの、そのまま完走しました。ログの抜粋です(時刻はUTCで、JSTでは13:05開始、22:45完了です)。
$ ssh node1 'tail -f ~/work/20260717/ingest-10k.log'
nohup: ignoring input
[04:05:49] resume: 13 pages / 17 chunks already in DB
[04:05:49] reading jawiki_content-20260712-00000.json.bz2
[04:05:58] HTTP retry 1/3 http://plamo-embedding.tailea8e2.ts.net/embed: Remote end closed connection without response
[04:05:58] HTTP retry 1/3 http://plamo-embedding.tailea8e2.ts.net/embed: Remote end closed connection without response
[04:05:58] HTTP retry 1/3 http://plamo-embedding.tailea8e2.ts.net/embed: Remote end closed connection without response
[04:05:58] HTTP retry 1/3 http://plamo-embedding.tailea8e2.ts.net/embed: Remote end closed connection without response
[04:06:50] 27/10000 chunks (2 pages, 0.16 chunks/s, ETA 16.9h)
[04:07:10] 35/10000 chunks (8 pages, 0.22 chunks/s, ETA 12.5h)
[04:07:51] 48/10000 chunks (12 pages, 0.25 chunks/s, ETA 10.9h)
[04:08:20] 57/10000 chunks (17 pages, 0.27 chunks/s, ETA 10.4h)
[04:08:41] 65/10000 chunks (22 pages, 0.28 chunks/s, ETA 9.9h)
[04:09:02] 73/10000 chunks (28 pages, 0.29 chunks/s, ETA 9.5h)
(中略)
[13:40:17] 9935/10000 chunks (4041 pages, 0.29 chunks/s, ETA 0.1h)
[13:40:59] 9943/10000 chunks (4045 pages, 0.29 chunks/s, ETA 0.1h)
[13:41:20] 9951/10000 chunks (4049 pages, 0.29 chunks/s, ETA 0.0h)
[13:41:56] 9960/10000 chunks (4051 pages, 0.29 chunks/s, ETA 0.0h)
[13:42:27] 9970/10000 chunks (4057 pages, 0.29 chunks/s, ETA 0.0h)
[13:42:49] 9978/10000 chunks (4062 pages, 0.29 chunks/s, ETA 0.0h)
[13:43:16] 9986/10000 chunks (4067 pages, 0.29 chunks/s, ETA 0.0h)
[13:43:55] 9997/10000 chunks (4068 pages, 0.29 chunks/s, ETA 0.0h)
[13:45:22] 10017/10000 chunks (4070 pages, 0.29 chunks/s, ETA 0.0h)
[13:45:22] done: 10017 chunks (4070 pages this run)
約9時間40分で10,017チャンク(4,083ページ、合計約824万トークン)が入りました。1チャンクあたりのトークン数は、上限1,024に対して平均822です。実効スループットは0.29チャンク/s(約240トークン/s)で、事前見積もりの0.26チャンク/s(約11時間)を1割ほど上回っています。見積もりの根拠にした付録の並列スイープは、計測中にOOMKillで片方のPodが一時離脱していた疑いがあるため、健全な2 Pod・同時4の実力値はこちらと考えてよさそうです。
投入時間帯(JST 13:05〜22:45)のノードCPUとメモリです。
投入中のノードCPU。推論を担うnode2 / node3だけが約10時間振動し続け、TiFlash用に空けたnode1はほぼ無風
node2 / node3のCPU使用率は、20〜97%の間を振動し続けます。同時4のリクエストは接続ごとに2 Podへランダムに分散するため、瞬間ごとにPodあたり0〜2件の推論が重なります。2件重なればSMT込みの論理コアまで埋まって90%を超え、1件なら50%前後になるという、付録で見た挙動を行き来している形です。一方、node1は投入スクリプトが動いているにもかかわらず、3%前後のままです。スクリプトの処理はHTTP応答待ちとINSERTだけなので負荷はほぼなく、nodeAffinityで狙った「TiFlash用にnode1を空ける」配置が投入中も維持できています。
ノードメモリ。node2 / node3は投入開始とともに上昇し、投入が終わっても元の水準へ戻らない
メモリで目を引くのは、投入が終わってもnode2 / node3が38〜40%のまま、投入前の31〜32%へ戻らない点です。正体はPLaMo Podでした。投入完了から約7時間後のアイドル状態で実測すると、Podのメモリは投入前より1〜1.8GiB増えたままです。
| PLaMo Pod | 投入前アイドル | 投入中ピーク(memory.peak) | 投入後アイドル |
|---|---|---|---|
| node2側 | 5.72GiB | 7.31GiB | 6.81GiB |
| node3側 | 5.45GiB | 7.81GiB | 7.21GiB |
増加分は、cgroupの内訳を見るとほぼ全量がanon(ヒープ)です。これはリークではなく、同時リクエストの推論用に確保したアクティベーションのメモリを、プロセスのアロケータ(glibc)がOSへ返さず保持し続けるためです。確保済みの領域は次の推論で再利用されるので、実害は「使用量がピークに張り付いて見える」ことだけで、放置して問題ありません。なお、ノード全体の増加分(2〜2.5GB)のうちPodの増加で説明できない残りは、書き込みを受けたTiKV側のキャッシュ類とみられます。
for p in $(kubectl -n plamo-embedding get pods -o name); do
echo "== $p"
# 現在の使用量とcgroup作成以降のピーク
kubectl -n plamo-embedding exec "$p" -- cat /sys/fs/cgroup/memory.current \
| awk '{printf "memory.current: %.2f GiB\n", $1/1073741824}'
kubectl -n plamo-embedding exec "$p" -- cat /sys/fs/cgroup/memory.peak \
| awk '{printf "memory.peak: %.2f GiB\n", $1/1073741824}'
# 内訳のうち匿名ページ(ヒープ)。currentとの差がファイルキャッシュ等
kubectl -n plamo-embedding exec "$p" -- grep -E '^anon ' /sys/fs/cgroup/memory.stat \
| awk '{printf "anon: %.2f GiB\n", $2/1073741824}'
done
== pod/plamo-embedding-7db9654b75-kl4kq (node2)
memory.current: 6.81 GiB
memory.peak: 7.31 GiB
anon: 6.78 GiB
== pod/plamo-embedding-7db9654b75-rn9wz (node3)
memory.current: 7.21 GiB
memory.peak: 7.81 GiB
anon: 7.19 GiB
むしろ見るべきはピーク値です。node3側のmemory.peakは7.81GiBで、引き上げ後の上限である8Giまで200MiB弱しか余裕がありませんでした。同時4でこの水準なので、並列度をさらに上げる余地は、メモリの観点ではほぼ残っていません。OOMKillの教訓に「上限の妥当性はアイドル時の実測ではなくmemory.peakで確認する」を付け加えておきます。
全チャンクの投入完了後、TiFlashレプリカの同期を待ってからCOMPACTを実行し、その後にHNSWインデックスを作ります。
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki <<'SQL'
ALTER TABLE wiki_embedding_chunks COMPACT;
CREATE VECTOR INDEX idx_wiki_embedding_chunks_embedding
ON wiki_embedding_chunks ((VEC_COSINE_DISTANCE(embedding))) USING HNSW;
SQL
ビルドの進捗はINFORMATION_SCHEMA.TIFLASH_INDEXESで確認できます。
mysql -h tidb.${TAILNET} -P 4000 -u root -e "
SELECT TIDB_DATABASE, TIDB_TABLE, INDEX_NAME,
ROWS_STABLE_INDEXED, ROWS_STABLE_NOT_INDEXED,
ROWS_DELTA_INDEXED, ROWS_DELTA_NOT_INDEXED, ERROR_MESSAGE
FROM INFORMATION_SCHEMA.TIFLASH_INDEXES
WHERE TIDB_DATABASE = 'bench_wiki'
AND TIDB_TABLE = 'wiki_embedding_chunks';"
# ROWS_STABLE_NOT_INDEXED と ROWS_DELTA_NOT_INDEXED が0、
# ERROR_MESSAGE が空ならビルド完了
1万チャンク時点の実測です。COMPACTとインデックス作成を合わせたコマンド全体が8.8秒で返りました。TiFlashのサーバーログを見ると、HNSWインデックスのビルド本体は7.9秒です(抜粋、時刻はUTC)。
[2026/07/17 21:25:14.602] EnsureStableLocalIndex - Begin building index, dm_files=[dmf_11(v=0)]
[2026/07/17 21:25:22.456] EnsureStableLocalIndex - Finish building index, dm_files=[dmf_11(v=0)]
DDLが返ったのはビルド完了の0.2秒後でした。CREATE VECTOR INDEXは既存データのビルド完了を待ってから完了するため、返ってきた時点でTIFLASH_INDEXESは全行インデックス済みを示します。埋め込み生成の9時間40分に対して、1万ベクトル(2048次元)のインデックス作成は8秒と、完全に誤差の範囲でした。
サイズも見ておきます。HNSWインデックスの実体はTiFlashのDMFileディレクトリにidx_<index_id>.vectorとして置かれ、79.6MiBでした。embedding列の生データ(10,017行×2048次元×4バイト≒78.3MiB)とほぼ同じサイズで、HNSWがインデックス内にベクトル本体も持つ構造のため、ベクトル分のディスクはおおよそ2倍になります。
kubectl -n tidb-cluster exec basic-tiflash-0 -c tiflash -- \
ls -la /data0/db/data/t_438/stable/dmf_11
-rw-r--r-- 1 root root 393408 Jul 17 21:25 0.merged
-rw-r--r-- 1 root root 14475467 Jul 17 21:25 5.dat
-rw-r--r-- 1 root root 82383193 Jul 17 21:25 7.dat # embedding列(理論値82,059,264Bとほぼ一致)
-rw-r--r-- 1 root root 83509628 Jul 17 21:25 idx_3.vector # HNSWインデックス本体
-rw-r--r-- 1 root root 1695 Jul 17 21:25 meta
-rw-r--r-- 1 root root 1722 Jul 17 21:25 v1.meta
「データ投入→COMPACT→インデックス作成」の順序は重要です。別の検証で、行が入っていない状態でHNSWインデックスを先行作成したところ、TiFlash(v8.5.7)が既存DMFileへのインデックスビルドで0除算を起こし、終了コード136でCrashLoopに陥りました。
Received signal Floating point exception(8).
Integer divide by zero.
FramedChecksumReadBuffer<XXH3>::doSeek
DMFileVectorIndexWriter::buildIndexForFile
厄介なのは、TiDB側でインデックスをDROPしても、TiFlashがPVC上に残ったlocal-indexタスクを起動直後に再開し、クラッシュを繰り返す点です。復旧にはSET TIFLASH REPLICA 0でレプリカを一度切り離し、Podを再作成する必要がありました。チャンクテーブルのembeddingをNOT NULLにしているのも、この事象を踏まえて「埋め込みのない行がインデックスビルドの対象にならない」ことを保証するためです。
同じ近傍検索クエリを3つの実行経路で実行し、実行計画とレイテンシを比較します。対象は投入済みの1万チャンク(10,017行)です。以降、経路の呼び方は次の表で固定します。HNSWという語は、インデックスの実装を指すときだけ使います。
| パターン | 呼び方 | 実行場所 | 距離計算 |
|---|---|---|---|
| 1 | TiKV全件スキャン | TiKV(行指向) | 全件と比較(近似なし) |
| 2 | TiFlash全件スキャン | TiFlash(列指向) | 全件と比較(近似なし) |
| 3 | ANN | TiFlash + HNSWインデックス | グラフ探索(近似) |
HNSWインデックスは前章で作成済みのため、パターン2(TiFlash全件スキャン)はそのままではANNに乗ってしまいます。インデックスを避ける書き方はパターン2の節で説明します。
検索クエリの埋め込みはPLaMoのmode=queryで生成します(ドキュメント側のmode=documentと非対称です)。
QVEC=$(curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"日本の城の石垣の構造","mode":"query"}' | jq -c '.vector')
インデックスもTiFlashも使わない経路です。VECTOR型と距離関数だけで動くため、TiFlashを追加していない素のTiDBの実力に相当します。
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
SELECT /*+ READ_FROM_STORAGE(TIKV[c]) */
page_id, title,
VEC_COSINE_DISTANCE(embedding, '${QVEC}') AS distance
FROM wiki_embedding_chunks AS c
ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}')
LIMIT 10;"
実行計画はcop[tikv]のTableFullScanになり、全チャンクとの距離計算が行指向ストレージ上で走ります。
+----------------------------------+----------+-----------+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| id | estRows | task | access object | operator info |
+----------------------------------+----------+-----------+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Projection_8 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#12 |
| └─Projection_16 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding |
| └─TopN_9 | 10.00 | root | | Column#13, offset:0, count:10 |
| └─Projection_17 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#13 |
| └─TableReader_15 | 10.00 | root | | data:TopN_14 |
| └─TopN_14 | 10.00 | cop[tikv] | | vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...]), offset:0, count:10 |
| └─TableFullScan_13 | 10017.00 | cop[tikv] | table:c | keep order:false |
+----------------------------------+----------+-----------+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
同じ全件スキャンを列指向のTiFlashで実行します。近似なしの正確な結果(いわゆるexact search)を返すため、後述のrecall@105の正解データもこの経路で作ります。
注意点として、HNSWインデックスがあるとORDER BY 距離関数 + LIMITの形は自動的にANNへ乗るため、hintでTiFlashを指定するだけでは全件スキャンになりません。ORDER BY式に+ 0を足して、インデックスが効く形から意図的に外します(0を足しても順序は変わらないため、結果は近似なしの全件スキャンと同一です)。パターン1とのdiffで示します。
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
-SELECT /*+ READ_FROM_STORAGE(TIKV[c]) */
+SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */
page_id, title,
VEC_COSINE_DISTANCE(embedding, '${QVEC}') AS distance
FROM wiki_embedding_chunks AS c
- ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}')
+ ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}') + 0
LIMIT 10;"
EXPLAINのTableFullScanから、次節で触れるannIndex表記が消えていれば全件スキャンです。実行計画上はソートキーがplus(vec_cosine_distance(...), 0)になっており、インデックスの適用対象から外れたことが読み取れます。列指向フォーマットとベクトル化実行の効果がどの程度出るかを、パターン1との差分で見ます。
+----------------------------------------+----------+--------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| id | estRows | task | access object | operator info |
+----------------------------------------+----------+--------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Projection_8 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#12 |
| └─Projection_21 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding |
| └─TopN_11 | 10.00 | root | | Column#14, offset:0, count:10 |
| └─Projection_22 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, plus(vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...]), 0)->Column#14 |
| └─TableReader_18 | 10.00 | root | | MppVersion: 2, data:ExchangeSender_17 |
| └─ExchangeSender_17 | 10.00 | mpp[tiflash] | | ExchangeType: PassThrough |
| └─Projection_19 | 10.00 | mpp[tiflash] | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding |
| └─TopN_16 | 10.00 | mpp[tiflash] | | Column#13, offset:0, count:10 |
| └─Projection_20 | 10017.00 | mpp[tiflash] | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, plus(vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...]), 0)->Column#13 |
| └─TableFullScan_14 | 10017.00 | mpp[tiflash] | table:c | keep order:false |
+----------------------------------------+----------+--------------+---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
HNSWインデックス作成後は、ORDER BY 距離関数 + LIMITの形のクエリが自動的にインデックスへ乗ります。SQLはパターン2から+ 0を外しただけ、つまりパターン1のhintをTIFLASHに変えた素の形です。パターン2とのdiffで示します。
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */
page_id, title,
VEC_COSINE_DISTANCE(embedding, '${QVEC}') AS distance
FROM wiki_embedding_chunks AS c
- ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}') + 0
+ ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}')
LIMIT 10;"
インデックスが使われているかは、EXPLAINでTableFullScanのoperator infoにannIndex:COSINE(...)が現れるかで判断します。HNSW利用時もexecutor名はTableFullScanのままである点に注意してください。
TableFullScan_21 mpp[tiflash] table:c,
index:idx_wiki_embedding_chunks_embedding(embedding),
annIndex:COSINE(embedding..[...], limit:10)
+----------------------------------------+---------+--------------+---------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| id | estRows | task | access object | operator info |
+----------------------------------------+---------+--------------+---------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Projection_8 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#12 |
| └─Projection_27 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding |
| └─TopN_12 | 10.00 | root | | Column#14, offset:0, count:10 |
| └─Projection_28 | 10.00 | root | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#14 |
| └─TableReader_24 | 10.00 | root | | MppVersion: 2, data:ExchangeSender_23 |
| └─ExchangeSender_23 | 10.00 | mpp[tiflash] | | ExchangeType: PassThrough |
| └─Projection_25 | 10.00 | mpp[tiflash] | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding |
| └─TopN_22 | 10.00 | mpp[tiflash] | | Column#13, offset:0, count:10 |
| └─Projection_26 | 10.00 | mpp[tiflash] | | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#13 |
| └─TableFullScan_21 | 10.00 | mpp[tiflash] | table:c, index:idx_wiki_embedding_chunks_embedding(embedding) | keep order:false, annIndex:COSINE(embedding..[-1.5,1.7,3.8,-2.8,-1,(2043 more)...], limit:10) |
+----------------------------------------+---------+--------------+---------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
なお、件数が少ないうちは、ヒントなしだとオプティマイザがTiKV全件スキャンを選ぶことがあります。経路を比較する計測では、READ_FROM_STORAGEヒントでストレージエンジンを固定します。
計測方法は次の通りです。
mode=queryで埋め込み、3パターンを各1回実行します。レイテンシの分布はこの20サンプルから取ります同一クエリの繰り返しで試行回数を稼がないのには理由があります。最初は同一クエリ20回で計測したところ、TiKV全件スキャンがp50 9.6msという不自然に速い値になりました。原因はTiDBのcoprocessor cacheで、同じEXPLAIN ANALYZEを2回続けて実行すると確認できます。
# 同じクエリのEXPLAIN ANALYZEを2回続けて打つ
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
EXPLAIN ANALYZE SELECT /*+ READ_FROM_STORAGE(TIKV[c]) */ page_id, title,
VEC_COSINE_DISTANCE(embedding, '${QVEC}') AS distance
FROM wiki_embedding_chunks AS c
ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}')
LIMIT 10;"
1回目はcopr_cache_hit_ratio: 0.00で全体174.5ms(TiKVが10,017キーを172msかけて処理)、2回目はcopr_cache_hit_ratio: 1.00で全体1.58msです。2回目はTiKVで距離計算をしておらず、TiDBがキャッシュ済みのTopN結果を返しているだけです。TiFlash(MPP)経路はこのキャッシュを使わないため、同一クエリの繰り返しではTiKVだけが不当に速く見えます。実際のベクトル検索ではクエリごとに埋め込みが異なり、このキャッシュに乗りません。そのため、実態に合わせて「異なる20クエリ×各1回」で計測します。
+----------------------------------+----------+---------+-----------+---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+---------+
| id | estRows | actRows | task | access object | execution info | operator info | memory | disk |
+----------------------------------+----------+---------+-----------+---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+---------+
| Projection_8 | 10.00 | 10 | root | | time:174.5ms, loops:2, RU:1764.60, Concurrency:OFF | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#12 | 97.4 KB | N/A |
| └─Projection_16 | 10.00 | 10 | root | | time:174.4ms, loops:2, Concurrency:OFF | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding | 97.7 KB | N/A |
| └─TopN_9 | 10.00 | 10 | root | | time:174.4ms, loops:2 | Column#13, offset:0, count:10 | 81.2 KB | 0 Bytes |
| └─Projection_17 | 10.00 | 10 | root | | time:174.3ms, loops:2, Concurrency:OFF | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#13 | 80.7 KB | N/A |
| └─TableReader_15 | 10.00 | 10 | root | | time:174.1ms, loops:2, cop_task: {num: 1, max: 174ms, proc_keys: 10017, tot_proc: 172ms, tot_wait: 646.5µs, copr_cache_hit_ratio: 0.00, build_task_duration: 4.05µs, max_distsql_concurrency: 1}, rpc_info:{Cop:{num_rpc:1, total_time:173.9ms}} | data:TopN_14 | 80.7 KB | N/A |
| └─TopN_14 | 10.00 | 10 | cop[tikv] | | tikv_task:{time:172ms, loops:10}, scan_detail: {total_process_keys: 10017, total_process_keys_size: 111856352, total_keys: 10018, get_snapshot_time: 477.1µs, rocksdb: {delete_skipped_count: 1832, key_skipped_count: 21865, block: {cache_hit_count: 2350}}}, time_detail: {total_process_time: 172ms, total_suspend_time: 137.1µs, total_wait_time: 646.5µs, total_kv_read_wall_time: 74ms, tikv_wall_time: 172.8ms} | vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...]), offset:0, count:10 | N/A | N/A |
| └─TableFullScan_13 | 10017.00 | 10017 | cop[tikv] | table:c | tikv_task:{time:74ms, loops:10} | keep order:false | N/A | N/A |
+----------------------------------+----------+---------+-----------+---------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+---------+
+----------------------------------+----------+---------+-----------+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+---------+
| id | estRows | actRows | task | access object | execution info | operator info | memory | disk |
+----------------------------------+----------+---------+-----------+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+---------+
| Projection_8 | 10.00 | 10 | root | | time:1.58ms, loops:2, RU:0.48, Concurrency:OFF | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#12 | 97.4 KB | N/A |
| └─Projection_16 | 10.00 | 10 | root | | time:1.51ms, loops:2, Concurrency:OFF | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding | 97.7 KB | N/A |
| └─TopN_9 | 10.00 | 10 | root | | time:1.51ms, loops:2 | Column#13, offset:0, count:10 | 81.2 KB | 0 Bytes |
| └─Projection_17 | 10.00 | 10 | root | | time:1.45ms, loops:2, Concurrency:OFF | bench_wiki.wiki_embedding_chunks.page_id, bench_wiki.wiki_embedding_chunks.title, bench_wiki.wiki_embedding_chunks.embedding, vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...])->Column#13 | 80.7 KB | N/A |
| └─TableReader_15 | 10.00 | 10 | root | | time:1.36ms, loops:2, cop_task: {num: 1, max: 1.27ms, proc_keys: 0, tot_proc: 2.44µs, tot_wait: 619.9µs, copr_cache_hit_ratio: 1.00, build_task_duration: 6.81µs, max_distsql_concurrency: 1}, rpc_info:{Cop:{num_rpc:1, total_time:1.25ms}} | data:TopN_14 | 80.6 KB | N/A |
| └─TopN_14 | 10.00 | 10 | cop[tikv] | | tikv_task:{time:172ms, loops:10}, scan_detail: {get_snapshot_time: 580.1µs, rocksdb: {block: {}}}, time_detail: {total_process_time: 2.44µs, total_wait_time: 619.9µs, tikv_wall_time: 785.3µs} | vec_cosine_distance(bench_wiki.wiki_embedding_chunks.embedding, [-1.5,1.7,3.8,-2.8,-1,(2043 more)...]), offset:0, count:10 | N/A | N/A |
| └─TableFullScan_13 | 10017.00 | 10017 | cop[tikv] | table:c | tikv_task:{time:74ms, loops:10} | keep order:false | N/A | N/A |
+----------------------------------+----------+---------+-----------+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+---------+
# /// script # requires-python = ">=3.11" # dependencies = ["pymysql>=1.1"] # /// """TiDBベクトル検索の3経路レイテンシとrecall@10を計測する。 同一クエリを繰り返すとTiKV経路がTiDBのcoprocessor cacheに乗って 実測にならないため、異なる20クエリを各1回ずつ流して分布を取る。 usage: TAILNET=... uv run bench_search.py """ import json import math import os import time import urllib.request import pymysql TAILNET = os.environ["TAILNET"] EMBED_URL = f"http://plamo-embedding.{TAILNET}/embed" QUERIES = [ "日本の城の石垣の構造", "相対性理論における時間の遅れ", "江戸時代の参勤交代の制度", "ワクチンで免疫がつく仕組み", "新幹線が開業するまでの歴史", "火山の噴火が起きる仕組み", "平安時代の貴族の暮らし", "サッカーのオフサイドのルール", "株式会社と合同会社の違い", "太陽系の惑星の並び順", "発酵食品と微生物の関係", "CPUが計算を実行する仕組み", "織田信長と本能寺の変", "地震の震度とマグニチュードの違い", "世界遺産に登録される条件", "オリンピックの開催地の決め方", "日本酒の醸造工程", "遺伝子の突然変異と進化", "気候変動が農業に与える影響", "印象派の画家と代表作", ] # (hint, ORDER BY式)。tiflash_exactの「+ 0」はANNインデックス回避(本文参照) PATTERNS = { "tikv_full": ("READ_FROM_STORAGE(TIKV[c])", "VEC_COSINE_DISTANCE(embedding, %s)"), "tiflash_exact": ("READ_FROM_STORAGE(TIFLASH[c])", "VEC_COSINE_DISTANCE(embedding, %s) + 0"), "tiflash_ann": ("READ_FROM_STORAGE(TIFLASH[c])", "VEC_COSINE_DISTANCE(embedding, %s)"), } def embed_query(text: str) -> tuple[str, float]: body = json.dumps({"text": text, "mode": "query"}).encode() req = urllib.request.Request(EMBED_URL, data=body, headers={"Content-Type": "application/json"}) t0 = time.perf_counter() vec = json.load(urllib.request.urlopen(req, timeout=60))["vector"] return json.dumps(vec), time.perf_counter() - t0 def search(cur, pattern: str, qvec: str) -> tuple[tuple, float]: hint, order_by = PATTERNS[pattern] sql = (f"SELECT /*+ {hint} */ chunk_id, title, " f"VEC_COSINE_DISTANCE(embedding, %s) AS distance " f"FROM wiki_embedding_chunks AS c " f"ORDER BY {order_by} LIMIT 10") t0 = time.perf_counter() cur.execute(sql, [qvec] * sql.count("%s")) return cur.fetchall(), time.perf_counter() - t0 def stats(label: str, samples: list[float]) -> None: s = sorted(samples) n = len(s) p50 = s[math.ceil(0.50 * n) - 1] * 1000 p95 = s[math.ceil(0.95 * n) - 1] * 1000 print(f"{label:14s} min={s[0] * 1000:6.1f}ms p50={p50:6.1f}ms " f"p95={p95:6.1f}ms max={s[-1] * 1000:6.1f}ms") def main() -> None: conn = pymysql.connect(host=f"tidb.{TAILNET}", port=4000, user="root", database="bench_wiki") cur = conn.cursor() # ウォームアップ(計測対象外のクエリで各経路を一度ずつ温める) warm, _ = embed_query("ウォームアップ用のクエリ") for pattern in PATTERNS: search(cur, pattern, warm) embed_times: list[float] = [] times: dict[str, list[float]] = {p: [] for p in PATTERNS} recalls: list[float] = [] for q in QUERIES: qvec, embed_sec = embed_query(q) embed_times.append(embed_sec) rows = {} for pattern in PATTERNS: rows[pattern], sec = search(cur, pattern, qvec) times[pattern].append(sec) exact = {r[0] for r in rows["tiflash_exact"]} ann = {r[0] for r in rows["tiflash_ann"]} recalls.append(len(exact & ann) / len(exact)) print(f"recall@10={recalls[-1]:.2f} {q}") print(f"mean recall@10={sum(recalls) / len(recalls):.2f} (n={len(QUERIES)})") stats("query_embed", embed_times) for pattern in PATTERNS: stats(pattern, times[pattern]) if __name__ == "__main__": main()
実行と結果です(20クエリの分布)。
TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix') uv run bench_search.py
| 検索経路 | min | p50 | p95 | max |
|---|---|---|---|---|
| パターン1: TiKV全件スキャン | 179.2ms | 187.7ms | 193.7ms | 194.1ms |
| パターン2: TiFlash全件スキャン | 68.8ms | 73.4ms | 130.0ms | 155.2ms |
| パターン3: ANN(HNSWインデックス) | 17.1ms | 21.7ms | 27.7ms | 30.5ms |
検索とは別に、前段で毎回発生するクエリ埋め込み(PLaMoのCPU推論)も同じ20回で計測しました。E2Eではこちらが支配項になります。
| 前処理 | min | p50 | p95 | max |
|---|---|---|---|---|
クエリ埋め込み(PLaMo mode=query) |
710.7ms | 764.9ms | 857.7ms | 871.8ms |
recall@10(パターン2の全件スキャンのtop10を正解としたパターン3の一致率)は、20クエリ平均0.83でした。ばらつきは0.50〜1.00で、クエリによっては10件中5件が正解と入れ替わります。
recall@10が測っているのは「全件スキャンが返す真の近傍top10のうち、ANNが何件を見つけられたか」というインデックスの忠実度で、検索結果が意味的にヒットしているかどうかとは別物です(そちらは検索精度の章で見ます)。取りこぼしが多かったクエリの1つ「日本の城の石垣の構造」(0.60)で、全件スキャンとANNのtop10を突き合わせると何が起きているかが見えます。
パターン2と3のSQLの取得列を変えた2本です(${QVEC}はクエリベクトルの生成の節と同じ「日本の城の石垣の構造」)。
# 全件スキャン(真の近傍top10 = 正解)
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */ LEFT(chunk_id,8) AS id, title, chunk_index,
ROUND(VEC_COSINE_DISTANCE(embedding, '${QVEC}'),4) AS distance
FROM wiki_embedding_chunks AS c
ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}') + 0
LIMIT 10;"
# ANNのtop10
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */ LEFT(chunk_id,8) AS id, title, chunk_index,
ROUND(VEC_COSINE_DISTANCE(embedding, '${QVEC}'),4) AS distance
FROM wiki_embedding_chunks AS c
ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}')
LIMIT 10;"
結果を突き合わせたものです。
全件スキャン top10(正解) ANNで見つかったか
1 0.3995 スラブ (chunk 0) ✓
2 0.4055 ジグザグ橋 (chunk 0) ✓
3 0.4118 走向 (chunk 2) ✗ 取りこぼし
4 0.4134 秋月氏 (chunk 3) ✗ 取りこぼし
5 0.4220 北山安夫 (chunk 1) ✓
6 0.4225 切山城 (chunk 1) ✗ 取りこぼし
7 0.4227 桜田門 (chunk 0) ✓
8 0.4243 鎹 (chunk 0) ✓
9 0.4249 桜田門 (chunk 2) ✓
10 0.4253 走向 (chunk 1) ✗ 取りこぼし
代わりにANNのtop10へ繰り上がったもの(真の11位以下)
0.4269 廊下 (漁場建築) (chunk 1)
0.4323 由良川 (鳥取県) (chunk 0)
0.4373 桜田門 (chunk 1)
0.4384 更上閣 (chunk 0)
一致は6件で6/10 = 0.60です。ANNは真の3位・4位・6位・10位を見つけ損ね、代わりに真の11位以下が繰り上がっています。ANN(HNSWインデックス)は全件と距離計算をせず、グラフの繋がりを辿って「近そうな方向」を探しに行くため、このクエリのようにtop10が0.40〜0.43の僅差に密集していると、辿った経路から漏れる点が出ます。逆にrecall@10=1.00だったクエリは本命が0.2台で明確に近く、グラフを辿れば迷わず到達できる形です。
recall@10=0.60 日本の城の石垣の構造
recall@10=0.80 相対性理論における時間の遅れ
recall@10=0.70 江戸時代の参勤交代の制度
recall@10=0.80 ワクチンで免疫がつく仕組み
recall@10=0.90 新幹線が開業するまでの歴史
recall@10=0.80 火山の噴火が起きる仕組み
recall@10=0.60 平安時代の貴族の暮らし
recall@10=0.90 サッカーのオフサイドのルール
recall@10=0.90 株式会社と合同会社の違い
recall@10=1.00 太陽系の惑星の並び順
recall@10=1.00 発酵食品と微生物の関係
recall@10=0.90 CPUが計算を実行する仕組み
recall@10=0.70 織田信長と本能寺の変
recall@10=0.90 地震の震度とマグニチュードの違い
recall@10=0.50 世界遺産に登録される条件
recall@10=0.90 オリンピックの開催地の決め方
recall@10=1.00 日本酒の醸造工程
recall@10=1.00 遺伝子の突然変異と進化
recall@10=0.90 気候変動が農業に与える影響
recall@10=0.80 印象派の画家と代表作
mean recall@10=0.83 (n=20)
この実測から得られた、1万チャンク時点の結論は「HNSWインデックスはまだ要らない」です。
レイテンシに続いて、意図した記事にヒットするかを確認します。ヒットの評価はANNの取りこぼし(recall@10=0.83)と切り分けたいので、正解はTiFlash全件スキャン(パターン2の+ 0付きSQL)で作り、章の最後にANNでもヒットが変わらないかを確認します。
セマンティック検索の価値は、キーワードが一致しないクエリでも目的の記事にヒットすることです。ヒットさせたい記事のタイトルの語を使わない言い換えクエリを12本用意し、それぞれtop3(タイトル・チャンク番号・distance・本文冒頭)を確認しました。
# /// script # requires-python = ">=3.11" # dependencies = ["pymysql>=1.1"] # /// """言い換え・表記揺れクエリの検索精度を目視確認する。 各クエリをTiFlash全件スキャン(`+ 0`付き)でtop3まで取り、 タイトル・chunk_index・distance・本文冒頭を表示する。 top1がchunk_index > 0の場合は、同じページの先頭チャンク(chunk_index=0)の distanceも表示する(「1記事1ベクトル(先頭1024トークン)相当」との比較用)。 usage: TAILNET=... uv run eval_quality.py """ import json import os import time import urllib.request import pymysql TAILNET = os.environ["TAILNET"] EMBED_URL = f"http://plamo-embedding.{TAILNET}/embed" QUERIES = [ # 言い換え(タイトルの語を使わない) "地球の表面の7割を覆う塩水の領域", "都市ガスの原料になる化石燃料", "人間の背の高さの平均や測り方", "火星にある太陽系最大級の峡谷", "マルクスとともに資本主義を批判した思想家", "生まれつき顔の筋肉が動かず表情が作れない難病", "ヤードポンド法で使われる体積の単位", "サンフランシスコ湾岸地域の交通系ICカード", "家庭で使った水の量を測る器具", "将棋で光速の寄せと呼ばれた棋士", "異なる品種を掛け合わせること", # 表記揺れ(ベニス⇔ヴェネツィア) "ベニス共和国と東ローマ帝国の戦い", ] SQL_TOP3 = """ SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */ page_id, title, chunk_index, LEFT(content, 90) AS head, VEC_COSINE_DISTANCE(embedding, %s) AS distance FROM wiki_embedding_chunks AS c ORDER BY VEC_COSINE_DISTANCE(embedding, %s) + 0 LIMIT 3 """ SQL_CHUNK0 = """ SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */ VEC_COSINE_DISTANCE(embedding, %s) AS distance FROM wiki_embedding_chunks AS c WHERE page_id = %s AND chunk_index = 0 """ def embed_query(text: str) -> str: body = json.dumps({"text": text, "mode": "query"}).encode() req = urllib.request.Request(EMBED_URL, data=body, headers={"Content-Type": "application/json"}) return json.dumps(json.load(urllib.request.urlopen(req, timeout=60))["vector"]) def main() -> None: conn = pymysql.connect(host=f"tidb.{TAILNET}", port=4000, user="root", database="bench_wiki") cur = conn.cursor() for q in QUERIES: qvec = embed_query(q) cur.execute(SQL_TOP3, [qvec, qvec]) rows = cur.fetchall() print(f"\n## {q}") for page_id, title, chunk_index, head, distance in rows: head = head.replace("\n", " ") print(f" {distance:.4f} {title} (chunk {chunk_index}) | {head}") top = rows[0] if top[2] > 0: cur.execute(SQL_CHUNK0, [qvec, top[0]]) d0 = cur.fetchone()[0] print(f" -> top1と同ページの先頭チャンク(chunk 0): {d0:.4f} " f"(チャンク方式との差 {d0 - top[4]:+.4f})") if __name__ == "__main__": main()
結果は、12クエリ中11クエリで意図した記事がtop1でした。外れた1つ(「マルクスとともに資本主義を批判した思想家」)も、top1は強く関連する「第一インターナショナル創立宣言」で、本命のフリードリヒ・エンゲルスがtop2に入ります。代表例を抜粋します。
| クエリ | top1 (distance) | 補足 |
|---|---|---|
| 家庭で使った水の量を測る器具 | 水道メーター (0.2572) | クエリに「水道」も「メーター」もなし |
| ベニス共和国と東ローマ帝国の戦い | ヴェネツィア・東ローマ戦争 (1118年) (0.2554) | ベニス⇔ヴェネツィアの表記揺れを吸収 |
| ヤードポンド法で使われる体積の単位 | ガロン (0.2708) | |
| 地球の表面の7割を覆う塩水の領域 | 海 (0.2728) | |
| 異なる品種を掛け合わせること | 交雑 (0.3302) | 記事側の表現は「交雑」「異種交配」 |
| 生まれつき顔の筋肉が動かず表情が作れない難病 | メビウス症候群 (0.3677) | 症状の描写だけで病名に到達 |
今回の観測範囲では、意図した記事はdistance 0.21〜0.37に収まり、無関係な記事はおおむね0.4より遠いという分布でした。
$ TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix') uv run eval_quality.py
## 地球の表面の7割を覆う塩水の領域
0.2728 海 (chunk 0) | 海(うみ、英: the sea または the ocean)は、地球上の陸地以外の部分で、海水に満たされたところ。 大小さまざまな広がり方があり、特に大きな広がり(だけ)は海洋(t
0.3106 海 (chunk 2) | 一般的な海域においては海からの水分や風が雨をもたらし降水量が多くなる傾向がある。寒流が流れる海岸部においては、上層の空気は暖かいのに対し、下層の空気は寒流によって冷やされるため、上
0.3114 海 (chunk 9) | た地名が数多く存在する。ただし、火星には地質時代には海があった可能性がある。 木星や土星の氷衛星のいくつかは、氷の地殻の下に液体の水の海があると推測されている。エウロパ、ガニメデ、
## 都市ガスの原料になる化石燃料
0.2823 天然ガス (chunk 7) | れた天然ガスは、-113℃以上に暖められるまでは空気よりも重いため、極低温のガスが地上に滞留する。LNGタンクが作られた初期の1944年10月20日、アメリカ合衆国のオハイオ州クリ
0.3019 天然ガス (chunk 0) | 天然ガス(てんねんガス、英: natural gas)とは、メタンを主成分とし、エタンやプロパンなどを含む化石燃料の一種。 気体燃料は天然ガス、石炭系ガス(石炭ガス、水性ガス、発生
0.3168 天然ガス (chunk 10) | 新聞』朝刊2018年12月4日(企業3面)2018年12月21日閲覧。 ^ Johnson, Jeff. LNG WEIGHS ANCHOR. Chemical & Enginee
-> top1と同ページの先頭チャンク(chunk 0): 0.3019 (チャンク方式との差 +0.0195)
## 人間の背の高さの平均や測り方
0.2114 身長 (chunk 0) | 身長(しんちょう)は、人間(ヒト)が直立した時の、床又は地面から頭頂までの高さ。身の丈(みのたけ)、上背(うわぜい)、背丈(せたけ)、立っ端(たっぱ)とも言う。通常、メートル法を使
0.2684 身長 (chunk 8) | にもより、中年期から始まる人もいるが、高齢者には普遍的な傾向が見られる。この身長の低下は、乾燥による椎間板の高さの低下、軟部組織の萎縮、変性疾患に伴う姿勢の変化などが原因である。2
0.2775 身長 (chunk 5) | 。 先行人類ではホモ・ハイデルベルゲンシスの身長が男性で175 cm、女性で157 cmくらいと推定されている。また、ホモ・ネアンデルターレンシスの身長は男性で166 cm、女性で
## 火星にある太陽系最大級の峡谷
0.2724 マリネリス峡谷 (chunk 1) | マッチ記念ステーション→ ジェラルド・ソッフェン記念ステーション→ 立体的に見たマリネリス峡谷 マリネリス峡谷の標高図 メラス谷の立体モデル 峡谷内のクレーターで見つかった塩水の流
0.2779 マリネリス峡谷 (chunk 0) | マリネリス峡谷(ラテン語: Valles Marineris ヴァリス・マリネリス)とは、火星の赤道に沿って伸びる巨大な峡谷。マリナー峡谷(英: Mariner Valleys)と
0.3686 ノクティス迷路 (chunk 0) | ノクティス迷路(英: Noctis Labyrinthus; "the labyrinth of the night" 夜の迷路)は、火星のマリネリス峡谷とタルシス地域の境にある迷
-> top1と同ページの先頭チャンク(chunk 0): 0.2779 (チャンク方式との差 +0.0056)
## マルクスとともに資本主義を批判した思想家
0.3743 第一インターナショナル創立宣言 (chunk 3) | 大月書店、1959年。 ハリンリヒ・ゲムコー(ドイツ語版),マルクス=レーニン主義研究所 著、土屋保男,松本洋子 訳『フリードリヒ・エンゲルス 一伝記(上)、(下)』大月書店、1
0.3750 フリードリヒ・エンゲルス (chunk 77) | ・イデオロギー』(マルクスとの共著)(ドイツ語: Die deutsche Ideologie, (mit Marx) 1845) 『共産主義の原理』(ドイツ語: Grundsät
0.3791 フリードリヒ・エンゲルス (chunk 87) | 大月書店、1976年。 大内兵衛『マルクス・エンゲルス小伝』岩波書店、1964年。 トリストラム・ハント(英語版) 著、東郷えりか 訳『エンゲルス: マルクスに将軍と呼ばれた男
-> top1と同ページの先頭チャンク(chunk 0): 0.4953 (チャンク方式との差 +0.1210)
## 生まれつき顔の筋肉が動かず表情が作れない難病
0.3677 メビウス症候群 (chunk 0) | メビウス症候群(メビウスしょうこうぐん、Möbius (或は Moebius) syndrome)は、非常に稀な神経異常。 メビウス症候群は、1888年に神経学者のパウル・メビウス
0.4178 キャシー中島 (chunk 7) | でも涙は見せたくない(その12)”. 産経新聞のウェブサイト (2016年7月18日). 2023年6月28日閲覧。 ^ “キャシー中島、皮膚がんの手術を受けていた…右目の下、テー
0.4355 斎藤洋介 (chunk 6) | 旭化成ホームズ/へーベルハウス「FREX-3」「DUET」(1987年) トヨタ実写版ドラえもん - 教習所指導員 ソニースタミナハンディカム(内藤剛志と共演) サークルK ヴィク
## ヤードポンド法で使われる体積の単位
0.2708 ガロン (chunk 1) | 穀物用 1824年、イギリスはエールガロンに近い値を英ガロンとして採用した。リットルが1キログラムの水の体積として定義されたのに影響を受けて、英ガロンは 10 ポンドの水の体積と定
0.2762 ガロン (chunk 0) | ガロン(gallon, 記号: gal)は、ヤード・ポンド法の体積の計量単位である。 国や用途によって各種のガロンの定義があるが、3.7 リットルから 4.6 リットルの範囲内にあ
0.3010 ガロン (chunk 3) | ^ 明治酪農牛乳容量の欄、沖縄明治乳業株式会社 ^ “沖縄の牛乳パックは「946ml」”. 沖縄おもしろミニ情報. 2018年4月21日時点のオリジナルよりアーカイブ。2018年4
-> top1と同ページの先頭チャンク(chunk 0): 0.2762 (チャンク方式との差 +0.0053)
## サンフランシスコ湾岸地域の交通系ICカード
0.2572 クリッパーカード (chunk 0) | クリッパーカード(Clipper Card)は、サンフランシスコ・ベイエリアで利用できる公共交通機関向け非接触型ICカードである。2021年現在、ベイエリアの24の公共交通機関で利
0.2878 クリッパーカード (chunk 1) | .org/web/20110504231738/http://www.mtc.ca.gov/news/current_topics/10-10/clipper_chinese.ht
0.3439 サウスベイ (サンフランシスコ・ベイエリア) (chunk 1) | 歴史公園、博物館、セントラルサンノゼ サンノゼ子供発見博物館、サンノゼ中心街 ノーマン・Y・ミネタ・サンノゼ国際空港、サンノゼ市 サンノゼ美術館、サンノゼ市 サンノゼ州立大学、サン
## 家庭で使った水の量を測る器具
0.2572 水道メーター (chunk 0) | 水道メーター(すいどうメーター)とは、水道での水の使用量を記録するための計器。検針や点検の際に用いる。量水器(りょうすいき)ともよばれる。 使用水量を立方メートル単位で3桁あるいは
0.4062 ガロン (chunk 2) | この定義値は、米国の(リットルによる)定義値の小数7桁目を四捨五入したものである。 会員制販売店のコストコでは、ガロン容量で販売している飲料やエンジンオイルが発売されているが、詳細
0.4120 酸素濃度計 (chunk 1) | 装置 食品包装 焼成炉内組込 グローブボックス 医療機器 排気ガスの分析 エンジンの制御 環境計測 窯業 ダイビング 東レエンジニアリング 新コスモス電機 横河電機 ^ a b c
## 将棋で光速の寄せと呼ばれた棋士
0.3038 谷川浩司 (chunk 21) | 1月16日時点のオリジナルよりアーカイブ。 ^ 「将棋 谷川浩司十七世名人 史上3人目の通算1400勝を達成」『NHK 日本放送協会』2025年1月15日。2025年1月16日時点
0.3228 谷川浩司 (chunk 10) | 率0.610)を達成。2019年1月22日には、第32期竜王戦4組ランキング戦で船江恒平に勝ち、中原誠を超え歴代4位の1309勝を、同年9月12日には、第78期順位戦B級1組で松尾
0.3328 谷川浩司 (chunk 17) | 神戸文化栄誉賞 2002年00月 - 神戸市特別表彰 2007年00月 - 兵庫県文化賞 2014年11月 - 紫綬褒章(将棋界12人目の受賞) 光速の寄せ 戦型別終盤の手筋(全5
-> top1と同ページの先頭チャンク(chunk 0): 0.4070 (チャンク方式との差 +0.1033)
## 異なる品種を掛け合わせること
0.3302 交雑 (chunk 0) | 交雑(こうざつ)または異種交配(いしゅこうはい)(英語: crossbreed)とは、生物学においては、異なる種や異なる亜種の関係にある動物・植物を特に人工的に組み合わせて交配させ
0.3803 カブ (chunk 8) | )2015年6月8日閲覧 ^ a b c d e 藤田智監修 NHK出版編 2019, p. 126. ^ a b c d e f g 主婦の友社編 2011, p. 179. ^
0.3897 カブ (chunk 7) | いた状態で販売されている事が多い。 ロシアでは、『おおきなかぶ』のように民話の題材になるほど馴染みのある野菜である。一方、カブがあまり好まれないフランスでは、大根役者に相当する「カ
## ベニス共和国と東ローマ帝国の戦い
0.2554 ヴェネツィア・東ローマ戦争 (1118年) (chunk 0) | ヴェネツィア・東ローマ戦争は、1118年にヴェネツィア共和国と東ローマ帝国との間で行われた一連の戦いである。ヴェネツィア共和国が勝利をおさめた。 1118年、父の死によって即位した
0.3167 アンコーナ (chunk 3) | 20,000m²以上の面積をもつ5角形の建物。船舶と共に市街へすぐ到達する伝染病の危険から軍事防衛幹部を守るため建てられた。のち、軍事病院や兵舎としても使われた・現在は文化展示に用
0.3261 メンターナ (chunk 1) | を占領してイタリア王国に組み込もうとしたジュゼッペ・ガリバルディが率いる義勇兵と、教皇軍・フランス軍部隊との間での戦闘が行われた(メンターナの戦い(英語版))。この戦いは、教皇軍・
ここまでの評価は全件スキャンでした。実運用で使うのはANNなので、recall@10=0.83の取りこぼしがヒットを壊すのかを、同じ12クエリで両経路のtop1を突き合わせて確認します。
# /// script # requires-python = ">=3.11" # dependencies = ["pymysql>=1.1"] # /// """言い換え12クエリを全件スキャン(exact)とANNの両経路で流し、top1が変わるか確認する。""" import json import os import urllib.request import pymysql TAILNET = os.environ["TAILNET"] EMBED_URL = f"http://plamo-embedding.{TAILNET}/embed" QUERIES = [ "地球の表面の7割を覆う塩水の領域", "都市ガスの原料になる化石燃料", "人間の背の高さの平均や測り方", "火星にある太陽系最大級の峡谷", "マルクスとともに資本主義を批判した思想家", "生まれつき顔の筋肉が動かず表情が作れない難病", "ヤードポンド法で使われる体積の単位", "サンフランシスコ湾岸地域の交通系ICカード", "家庭で使った水の量を測る器具", "将棋で光速の寄せと呼ばれた棋士", "異なる品種を掛け合わせること", "ベニス共和国と東ローマ帝国の戦い", ] SQL = """ SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */ title, chunk_index FROM wiki_embedding_chunks AS c ORDER BY VEC_COSINE_DISTANCE(embedding, %s){plus} LIMIT 1 """ def embed_query(text): body = json.dumps({"text": text, "mode": "query"}).encode() req = urllib.request.Request(EMBED_URL, data=body, headers={"Content-Type": "application/json"}) return json.dumps(json.load(urllib.request.urlopen(req, timeout=60))["vector"]) conn = pymysql.connect(host=f"tidb.{TAILNET}", port=4000, user="root", database="bench_wiki") cur = conn.cursor() same = 0 for q in QUERIES: qvec = embed_query(q) cur.execute(SQL.format(plus=" + 0"), [qvec]) exact = cur.fetchone() cur.execute(SQL.format(plus=""), [qvec]) ann = cur.fetchone() mark = "same" if exact == ann else "DIFF" same += exact == ann print(f"{mark} exact={exact[0]}(chunk {exact[1]}) ann={ann[0]}(chunk {ann[1]}) | {q}") print(f"top1一致: {same}/{len(QUERIES)}")
結果は12クエリ中11でtop1が一致しました。
| クエリ | 全件スキャンのtop1 | ANNのtop1 | 一致 |
|---|---|---|---|
| 地球の表面の7割を覆う塩水の領域 | 海 | 海 | ○ |
| 都市ガスの原料になる化石燃料 | 天然ガス | 天然ガス | ○ |
| 人間の背の高さの平均や測り方 | 身長 | 身長 | ○ |
| 火星にある太陽系最大級の峡谷 | マリネリス峡谷 | マリネリス峡谷 | ○ |
| マルクスとともに資本主義を批判した思想家 | 第一インターナショナル創立宣言 | 第一インターナショナル創立宣言 | ○ |
| 生まれつき顔の筋肉が動かず表情が作れない難病 | メビウス症候群 | メビウス症候群 | ○ |
| ヤードポンド法で使われる体積の単位 | ガロン | ガロン | ○ |
| サンフランシスコ湾岸地域の交通系ICカード | クリッパーカード | クリッパーカード | ○ |
| 家庭で使った水の量を測る器具 | 水道メーター | 水道メーター | ○ |
| 将棋で光速の寄せと呼ばれた棋士 | 谷川浩司 | 谷川浩司 | ○ |
| 異なる品種を掛け合わせること | 交雑 | カブ | × |
| ベニス共和国と東ローマ帝国の戦い | ヴェネツィア・東ローマ戦争 (1118年) | ヴェネツィア・東ローマ戦争 (1118年) | ○ |
$ TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix') uv run check_ann_top1.py
same exact=海(chunk 0) ann=海(chunk 0) | 地球の表面の7割を覆う塩水の領域
same exact=天然ガス(chunk 7) ann=天然ガス(chunk 7) | 都市ガスの原料になる化石燃料
same exact=身長(chunk 0) ann=身長(chunk 0) | 人間の背の高さの平均や測り方
same exact=マリネリス峡谷(chunk 1) ann=マリネリス峡谷(chunk 1) | 火星にある太陽系最大級の峡谷
same exact=第一インターナショナル創立宣言(chunk 3) ann=第一インターナショナル創立宣言(chunk 3) | マルクスとともに資本主義を批判した思想家
same exact=メビウス症候群(chunk 0) ann=メビウス症候群(chunk 0) | 生まれつき顔の筋肉が動かず表情が作れない難病
same exact=ガロン(chunk 1) ann=ガロン(chunk 1) | ヤードポンド法で使われる体積の単位
same exact=クリッパーカード(chunk 0) ann=クリッパーカード(chunk 0) | サンフランシスコ湾岸地域の交通系ICカード
same exact=水道メーター(chunk 0) ann=水道メーター(chunk 0) | 家庭で使った水の量を測る器具
same exact=谷川浩司(chunk 21) ann=谷川浩司(chunk 21) | 将棋で光速の寄せと呼ばれた棋士
DIFF exact=交雑(chunk 0) ann=カブ(chunk 8) | 異なる品種を掛け合わせること
same exact=ヴェネツィア・東ローマ戦争 (1118年)(chunk 0) ann=ヴェネツィア・東ローマ戦争 (1118年)(chunk 0) | ベニス共和国と東ローマ帝国の戦い
top1一致: 11/12
本命がはっきり近いクエリでは、取りこぼしが起きても下位の入れ替わりにとどまります。
問題は残る1本です。「異なる品種を掛け合わせること」では、全件スキャンの本命だった交雑(distance 0.3302、2位に0.05差をつけた明確なtop1)がANNのtop10から完全に消え、top1がカブ(0.3803)に入れ替わりました。
QVEC=$(curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"異なる品種を掛け合わせること","mode":"query"}' | jq -c '.vector')
mysql -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */ title, chunk_index,
ROUND(VEC_COSINE_DISTANCE(embedding, '${QVEC}'),4) AS distance
FROM wiki_embedding_chunks AS c
ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}')
LIMIT 10;"
カブ 8 0.3803 ← 全件スキャンでは2位。ANNではこれがtop1になる
カブ 7 0.3897
カブ 5 0.3908
ワイン醸造 1 0.3909
カブ 4 0.3966
水耕栽培 0 0.3993
ワイン醸造 11 0.4014
カブ 2 0.4036
カブ 1 0.4115
ワイン醸造 2 0.4155
取りこぼしは、計測結果の節で見た「僅差の下位の入れ替わり」だけでなく、まれにこのような「明確な本命の消失」としても現れます。1万チャンクなら全件スキャンが73msで引けるので、正確さが要る場面では全件スキャンを使うのが現実的です(レイテンシの章の結論とも整合します)。
Self-ManagedのTiDBにTiFlashを追加し、PLaMo-Embedding-1BをGPUなしのCPU推論でセルフホストして、日本語Wikipedia 1万チャンク(4,083記事、約824万トークン)のセマンティック検索をアプリケーションDB内で完結させました。本記事で得た実測値の一覧です。
| 項目 | 実測値 |
|---|---|
| 埋め込み生成(投入) | 10,017チャンク(約824万トークン) / 9時間40分(0.29チャンク/s ≈ 約240トークン/s、同時4) |
| HNSWインデックス | ビルド7.9秒、79.6MiB(ベクトル生データとほぼ同サイズ) |
| 検索レイテンシ(p50) | ANN 21.7ms < TiFlash全件スキャン 73.4ms < TiKV全件スキャン 187.7ms(速い順に期待通り) |
| 検索E2E(クエリ埋め込み込み) | 1秒弱(支配項はCPU推論の765ms) |
| recall@10(ANN) | 0.83(全件スキャンのtop10のうち平均8.3件が返る) |
| 言い換え12クエリ | 11クエリで意図した記事がtop1 |
結論は、投入と検索で非対称でした。
検索側は実用になります。言い換え・表記揺れに強い検索が1秒弱で返り、同期パイプラインも二重データも持たずに、業務条件の絞り込みをWHERE/JOINでそのまま重ねられます。この規模ならHNSWインデックスは不要で、近似なしの全件スキャンで十分です。ANNにはrecall@10=0.83の取りこぼし(全件スキャンのtop10のうち平均8.3件しか返らない)があり、まれに本命そのものが結果から消えます(実例は検索精度の章)。
一方の投入側は時間との勝負です。CPU推論は実測0.29チャンク/s(約240トークン/s、1日約2.5万チャンク≒2,000万トークン)が上限で、10万チャンクなら約4日、100万チャンクは約40日かかって非現実的です。コーパスの規模が投入計画をそのまま決めます。
PLaMoの章で触れた通り、同じPLaMo-Embedding-1BはCloudflare Workers AI(@cf/pfnet/plamo-embedding-1b)でも使えます。Workers AIの料金表では$0.019/100万トークン(1,689 Neurons/100万トークン)です。1万チャンク(実測の合計約824万トークン)の埋め込み生成で比べます。
| 本構成(CPU推論) | Workers AI | |
|---|---|---|
| 所要時間 | 9時間40分 | 分〜数十分(並列度とレート制限次第) |
| 費用 | 電気代25〜40円程度6 | 約$0.16(約24円)。無料枠(10,000 Neurons/日)の2日分以内 |
電気代 25〜40円。API料金 約24円。埋め込みの完了をただ待つ9時間40分、priceless。
所要時間は大きく異なる一方、費用はほぼ同額です。埋め込みを作ること自体が目的なら、Workers AIを選ばない理由はありません。それでも本構成を選ぶ理由は次の4つで、いずれも「埋め込み生成の効率」とは異なる軸です。
逆に、この4つに価値を感じないなら、埋め込み生成だけWorkers AIに寄せ、TiDB側(VECTOR + HNSWインデックス)をセルフホストする折衷構成が最もコスパの良い落とし所になるはずです。
1万チャンクで見えたのは「全件スキャンで十分」の世界でした。全件スキャンのレイテンシが線形に伸び、HNSWインデックスが必須になる10万・100万チャンクへのスケール(投入方法の選択、インデックス作成時間の伸び、検索レイテンシ)は、別記事で検証します。
本編で扱ったのはセマンティック検索ですが、検討の過程で調べた「日本語の全文検索を各データベースでやるなら何を使うか」を付録として残しておきます。
| DB | 日本語FTSの手段 |
|---|---|
| MySQL | ngramパーサ(組み込み、バイグラム) / MeCabパーサプラグイン(公式、形態素解析) |
| PostgreSQL | pg_bigm(バイグラム) / PGroonga + TokenMecab(形態素解析) |
| TiDB | Self-Managedは現状なし。Cloud Starter/EssentialのFULLTEXT INDEX(内蔵パーサ)のみ |
| OpenSearch / Elasticsearch | kuromoji analyzer(形態素解析) |
tsvector)は空白区切りの言語が前提で、日本語はそのままではトークナイズできないため、拡張の利用が前提になります。本編で触れた「CPUが余って見えるのに並列で伸びない」理由を深掘りします。結論から言うと、ほとんど伸びません。制約は2つあります。
同時実行数を変えた実測です。encode計測のembed-req.jsonを使い回し、xargs -Pで切り替えます。
bench() {
local P=$1 N=$2
local s=$(python3 -c 'import time; print(time.time())')
seq $N | xargs -P $P -I{} curl -s -o /dev/null \
-X POST "http://plamo-embedding.${TAILNET}/embed" \
-H 'Content-Type: application/json' -d @embed-req.json
local e=$(python3 -c 'import time; print(time.time())')
python3 -c "print(f'P=$P N=$N wall={$e-$s:.1f}s throughput={$N/($e-$s):.3f} req/s')"
}
bench 1 4
bench 2 8
bench 4 12
| 同時実行数 | リクエスト数 | 所要時間 | スループット | 並列なし比 |
|---|---|---|---|---|
| 1 (並列なし) | 4 | 28.0秒 | 0.143 req/s | 1.0倍 |
| 2 | 8 | 38.0秒 | 0.211 req/s | 1.48倍 |
| 4 | 12 | 46.1秒 | 0.260 req/s | 1.82倍 |
同時2の1.48倍は制約2の期待値(1.5倍)、同時4の1.82倍は制約1の上限(2倍)の約9割であり、実測は計算とおおむね整合します。ただし、この計測中にnode2側のPodがOOMKillで一時離脱していたことが後から判明しました(本編のインデックス化の章を参照)。そのため、特に同時2の1.48倍にはOOMKillの影響が含まれている可能性があり、参考値として扱う必要があります。
| 制約 | 解消策 | 効果 |
|---|---|---|
| Pod内: CPUバウンド | Podを足す / GPUを使う | 天井そのものが上がる |
| Pod間: ランダム振り分け | 同時実行数を積む(同時4で十分) | 天井の約9割まで到達(天井は超えない) |
なお、スループットが最大になるのは同時4ですが、1件あたりの処理が最速なのは同時1です(7秒。同時4では1件約15秒)。一括投入では総スループットを優先するため、本編では同時4を採用しています。
同時実行スイープ中のノードCPU。同時1→2→4と上げてもピークは60%前後にとどまり、論理CPU全体ではなく、推論に使う8スレッドの演算能力がボトルネックになっていることが分かる
CPU推論の実測(1チャンク7秒)があると、GPUに載せた場合の速度を桁レベルで見積もれます。1,024トークンの埋め込み生成に必要な計算量は、おおよそ「2 × パラメータ数 × トークン数 = 2 × 10⁹ × 1,024 ≒ 2 TFLOPs/チャンク」です。実測の7秒から逆算すると、Ryzen 7 7730Uの実効性能は約0.3 TFLOPSで、この見積もりと整合します。
同じ2 TFLOPsをDGX Spark(GB10、BF16の密行列演算性能でおおよそ100 TFLOPS級)で処理すると、理論値は20ms、実効値も30〜50ms程度と推定できます。GPUではバッチ処理も有効なため、スループットはさらに伸ばせます。
| CPU(本構成、2ノード) | DGX Spark(推定) | |
|---|---|---|
| 1チャンク | 7秒 | 30〜50ms |
| 10万チャンク | 約4.5日 | 1〜2時間 |
| 100万チャンク | 約45日(非現実的) | 半日〜1日 |
本編で「非現実的」とした100万チャンクの一括投入が、GPU 1台で一晩の処理に変わる計算です。なお、GPUで利用する場合、server.pyはDEVICE = "cuda" if torch.cuda.is_available() else "cpu"によりCUDAを選択します。
検索精度の章で使った言い換えクエリでの補足検証です。埋め込みの単位を「1記事1ベクトル」にするか「チャンクごと」にするかを比べます。PLaMoの入力上限は1,024トークンのため、1記事1ベクトル方式は実質「記事先頭の1,024トークンだけの埋め込み」になります。これは本編のテーブルの先頭チャンク(chunk_index = 0)そのものなので、WHERE chunk_index = 0で絞れば1記事1ベクトル方式の検索を再現できます。
クエリ「将棋で光速の寄せと呼ばれた棋士」で比べます。答えに当たる「光速の寄せ」への言及は、谷川浩司の記事(25チャンク、約2.5万トークン)の後半にしかありません。チャンク方式(本編の検索)では、top3を谷川浩司のチャンクが独占します。
0.3038 谷川浩司 (chunk 21)
0.3228 谷川浩司 (chunk 10)
0.3328 谷川浩司 (chunk 17) ← 「光速の寄せ」の記述を含むチャンク
同じクエリを先頭チャンクだけに絞って、1記事1ベクトル方式を再現します。
QVEC=$(curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"将棋で光速の寄せと呼ばれた棋士","mode":"query"}' | jq -c '.vector')
mysql -t -h tidb.${TAILNET} -P 4000 -u root bench_wiki -e "
SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */
title, chunk_index,
VEC_COSINE_DISTANCE(embedding, '${QVEC}') AS distance
FROM wiki_embedding_chunks AS c
WHERE chunk_index = 0
ORDER BY VEC_COSINE_DISTANCE(embedding, '${QVEC}') + 0
LIMIT 5;"
+--------------+-------------+---------------------+
| title | chunk_index | distance |
+--------------+-------------+---------------------+
| 石井秀吉 | 0 | 0.36196060406573516 |
| 村上文祥 | 0 | 0.37181090608354583 |
| 本田邦久 | 0 | 0.3723709897252001 |
| 栃光正之 | 0 | 0.37830654592072643 |
| 佃正樹 | 0 | 0.3912052429456595 |
+--------------+-------------+---------------------+
top5はいずれも別人の記事で、谷川浩司(先頭チャンクのdistanceは0.4070)は圏外です。長い記事では先頭1,024トークンに後半の内容が反映されないため、1記事1ベクトル方式では「記事のどこかに書いてあること」を検索できません。チャンク方式では、これをdistance 0.3038のtop1で取得できます。
代償は行数です。チャンク方式では4,083記事が10,017行(約2.5倍)になり、レイテンシの章で見た検索コストとストレージをその分支払います。短い記事(1チャンクに収まるもの)では両方式の検索対象が同じになるため、チャンク方式の価値はコーパスの記事長の分布で決まります。
HNSW(Hierarchical Navigable Small World)は、ANN(近似最近傍探索)インデックスの代表的なアルゴリズムです。クエリのベクトルに近いベクトルを、全件比較せずにグラフ探索で高速に見つけます。pgvector、OpenSearch、QdrantなどのベクトルDB・検索エンジンでも広く採用されているデファクトスタンダードで、TiDBのベクトルインデックスもこれを使います。 ↩
HeatWaveは、OracleがOCIなどのクラウドで提供するMySQLベースのマネージドサービスです。インメモリの分析エンジンやベクトル処理といった拡張機能を持ち、コミュニティ版のMySQLにない機能が先行して提供されます。ベクトルの距離関数もその1つです。 ↩
pgvectorのvector型はインデックス上限が2000次元のため、halfvec(半精度)へキャストしてインデックスを張るひと手間が必要です。fp16化による検索品質への影響は実用上ほぼ無視できます。 ↩
記事本文を埋め込みモデルの入力上限(1,024トークン)に合わせて分割した断片を、本記事ではチャンクと呼びます。1チャンクがテーブルの1行・1ベクトルに対応し、格納と検索の単位になります。分割方法はインデックス化の章で、実測のトークン数(1チャンク平均822トークン)は投入結果の節で扱います。 ↩
HNSWインデックスを使う近似検索(ANN)は、近似なしの全件スキャン(exact search)と同じ結果を返す保証がありません。そのズレを測るのがrecall@10で、全件スキャンのtop10のうちANNのtop10にも入っていた件数÷10です(全一致で1.0、6件なら0.6)。 ↩
高負荷だったnode2 / node3の2台を、ミニPCの実効消費電力40〜60Wと置いて 2台 × 10時間 × 31円/kWh で概算した値です。 ↩