TiDB Vector 検索実装 (PLaMo Embedding 1B + TiFlash)
起票日: 2026-07-15
関連: 2026-07-15 TiDB FTS 構成調査 (Vector + TiFlash 採用),
cluster/manifests/tidb-cluster/tidb-cluster.yaml,tools/dsql-cli/dsl-tidb/schema/,apps/blog-api/ステータス: 進行中
起票理由
前タスク (2026-07-15 TiDB FTS 構成調査) で「Self-Managed TiDB で日本語検索を実現するには Vector + TiFlash が唯一の TiDB 内完結案」という結論に至った。本 doc はその実装タスクとして、TiFlash 追加から検索エンドポイント公開までを 7 フェーズに分解し、各フェーズの具体的な作業内容・検証手順・本番展開手順を整理する。
全体アーキテクチャ
┌─ blog-api (search endpoint)
│ │
▼ │ q="..."
PLaMo Embedding Service ◀─┘
(k8s Pod, /chunks + /embed HTTP)
▲
│ PLaMO tokenizerでchunk化 + document embedding
tidb-embedder (ローカル backfill) ─┤
(source hashが変わった記事だけ) │
▼
TiDB (blog_dev / blog_prd)
article_embedding_chunks.embedding VECTOR(2048)
+ HNSW index on TiFlash replica
─ ─ ─ (最終フェーズ) ─ ─ ─
GitHub webhook → blog-api が記事変更時にchunkを再生成・記事単位で差し替え
書き込み経路 (初期): ローカルから
tools/tidb-embedderを実行し、記事を最大1024 tokens、128 tokens overlapでchunk化して専用テーブルへ保存する読み取り経路: blog-api がクエリをPLaMOでvectorizeし、近いchunkを検索して記事単位に集約する
継続更新 (webhook 経路): 全体が動き始めてから最後に組み込む。それまでは記事更新後に再度ローカルスクリプトを実行する
実装フェーズ (チェックボックス管理)
[x] Phase 1: TiFlash 追加 (manifest 編集 → apply → replica 確認) — 2026-07-15 完了
[x] Phase 2: PLaMo Embedding Service (k8s Pod + HTTP wrapper) — 2026-07-15 完了
[x] Phase 3:
articles.embeddingによる1記事1vectorの検証 — 2026-07-15 完了、Phase 4でchunk方式へ移行[x] Phase 4: PLaMO tokenizerによるchunk backfill + HNSWインデックス作成 — 2026-07-15 完了
[ ] Phase 5:
blog-api検索エンドポイント +apps/web検索 UI 実装[ ] Phase 6: 本番 (blog_prd) 適用 (TiFlash replica + DDL + backfill)
[ ] Phase 7: GitHub webhook 経路への embedding 生成組み込み (継続更新)
前提
~/.kube/config-myclusterから k8s クラスタに到達可能 (Tailscale 経由)mysqlクライアントで dev DB (blog_dev) に接続できるリポジトリルート (
shuntaka-dev) がカレントディレクトリ開発環境のインフラ適用 (
kubectl apply, DDL 実行, ツール実行) は ユーザーが手動で実施する。本 doc の作業指示は「編集内容 + 実行コマンド」のセットで書く
export KUBECONFIG=~/.kube/config-mycluster
export TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
想定所要時間 (dev 環境)
フェーズ |
目安時間 |
|---|---|
Phase 1: TiFlash 追加 |
10 - 20 分 |
Phase 2: PLaMo Embedding Service |
30 - 60 分 (初回 pull 込み) |
Phase 3: DDL 適用 |
5 分 |
Phase 4: tidb-embedder 実装 + 埋め戻し |
60 分 + データ量依存 |
Phase 5: 検索エンドポイント + Web UI |
120 - 240 分 |
Phase 6: 本番適用 |
30 分 |
Phase 7: webhook 組み込み (継続更新) |
30 分 |
Phase 1: TiFlash 追加
1-1. 前提確認
TiFlash は各 k8s ノードのローカルディスクに DeltaTree ストレージを持つ。dev では 1 replica で十分。
ノードに空きストレージがあること (
local-pathprovisioner の PV が確保できる領域)記事データは 2.5MB 程度と小さいので 20Gi あれば十分だが、将来の拡張を見越して 50Gi で確保する
MiniPC は Ryzen 7 7730U (amd64) なので
pingcap/tiflash:v8.5.7の amd64 manifest が pull される
kubectl -n tidb-cluster get tc basic -o jsonpath='{.spec.version}'
# → v8.5.7 が出ればよい
kubectl get nodes -o custom-columns=NAME:.metadata.name,ARCH:.status.nodeInfo.architecture
# → amd64 が並ぶこと (MiniPC = Ryzen 7 7730U)
1-2. TidbCluster manifest 編集
cluster/manifests/tidb-cluster/tidb-cluster.yaml の spec に tiflash セクションを追加する。既存の pd / tikv / tidb と同じ書式 (baseImage, requests, config, topologySpreadConstraints, additionalVolumes) を踏襲する。
追加内容 (末尾に追記):
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はconfig(tiflash 本体) とproxy(組み込み TiKV Learner) の 2 サブキーを持つ (pd/tikv/tidbのconfig: |単一とは書式が違う)storageClaimsは list。TiFlash は data ディレクトリを複数 PV に分散できる設計だが dev では 1 本log 出力先はデフォルトで良い (TiKV のような
[log.file]明示は不要。DeltaTree エンジンのログは stdout に流れてkubectl logsで見える)additionalVolumes(log emptyDir) は付けない — TiKV は debug 用に/var/log/tikvを分けているが TiFlash は使用頻度が低いので割愛
1-3. 反映 (ユーザー実行)
kubectl -n tidb-cluster apply -f cluster/manifests/tidb-cluster/tidb-cluster.yaml
Operator が basic-tiflash-0 StatefulSet を作成し、Pod が起動するのを待つ (5 - 15 分)。
kubectl -n tidb-cluster get pods -l app.kubernetes.io/component=tiflash -w
# basic-tiflash-0 4/4 Running が出れば OK
1-4. 動作確認 (ユーザー実行)
TiFlash が Region を認識しているか確認。
kubectl -n tidb-cluster exec -it basic-pd-0 -- /pd-ctl store
# store.labels に engine=tiflash が付いたノードが 1 つあること
MySQL クライアントから INFORMATION_SCHEMA.tiflash_replica を叩いて、まだ TiFlash replica を張ったテーブルが無いことを確認 (Phase 3 で articles に張る)。
mysql -h tidb.${TAILNET} -P 4000 -u root -e \
"SELECT * FROM INFORMATION_SCHEMA.TIFLASH_REPLICA;"
# → Empty set が出れば正常
Phase 1 完了条件
[x]
basic-tiflash-0Pod が4/4 Running[x]
pd-ctl storeに engine=tiflash が 1 store 見える (store id 7097, state Up, v8.5.7)[x]
INFORMATION_SCHEMA.TIFLASH_REPLICAが空 (テーブル未指定なので正常。TiFlash store のregion_count: 0からも確認可能)
Phase 2: PLaMo Embedding Service
2-1. 設計
PLaMo Embedding 1B (pfnet/plamo-embedding-1b, Apache 2.0) は AutoModel から encode_query(text) / encode_document(text) を呼び出すカスタム実装 (sentence-transformers ではない)。したがって Python FastAPI wrapper を自作 して HTTP エンドポイント化する。
依存:
torch(CPU),transformers,sentencepiece,fastapi,uvicornエンドポイント:
POST /embed— body{"text": "...", "mode": "query" | "document"}→{"vector": [float; N]}モデルは Pod 起動時に 1 回だけ load して常駐 (メモリに乗せる)
次元数 (N) は
/embedの query / document 両モードで 2048 次元 と実測済み記事本文 (~数千文字) 1 本の encode は CPU で 100 - 500 ms 程度を想定
2-2. ディレクトリ構成 (実装済み)
cluster/manifests/plamo-embedding/
├── server.py # FastAPI + transformers wrapper
├── Dockerfile # linux/amd64, model を build 時に焼き込み
├── deployment.yaml # Namespace + Deployment (2 replicas) + Service (ClusterIP)
└── build-and-push.sh # ローカル build & ghcr push
実体は cluster/manifests/plamo-embedding/ を参照。
2-3. 設計メモ
model 事前焼き込み: Dockerfile の build 時に HuggingFace から model を pull し image に含める。image サイズは ~5GB になるが、Pod 起動時のダウンロード時間 / ネットワーク依存を消せる (registry pull は node ごとに 1 回で済む)
snapshot_downloadを使う理由: 素直にAutoModel.from_pretrained()で pre-warm すると model を RAM に全展開して verify するため、Docker Desktop 既定メモリ (2-4GB) を超えて OOM (Killed,cannot allocate memory) になる。huggingface_hub.snapshot_downloadはファイル DL のみで RAM を使わないので build が通る。runtime のAutoModel.from_pretrained()は HF cache から読むので network 不要のままCPU 版 torch:
--index-url https://download.pytorch.org/whl/cpuで fetch。amd64 wheel が公式提供されている (target が Ryzen MiniPC = amd64)2 replicas: node1 は TiFlash 用に空け、node2 / node3 へ PLaMo を1 Podずつ配置する。
podAntiAffinityで同居を禁止するDeployment strategy: RollingUpdate:
maxSurge: 0,maxUnavailable: 1とし、更新中に追加 Pod を作らずモデル分のメモリ増加を防ぐTailnet 公開:
cluster/manifests/tailscale/plamo-embedding-public.yamlでplamo-embedding.<tailnet>をServiceへ接続する。port-forward svc/...は1 Podへ直接転送されるため、複数Podへの分散には使わない依存バージョン (Dockerfile):
torch==2.5.1,transformers==4.46.0,sentencepiece==0.2.0,fastapi==0.115.4,uvicorn==0.32.0,pydantic==2.9.2を pin--provenance=false --sbom=false(build-and-push.sh): buildx はデフォルトで OCI index に attestation manifest を追加するが、k3s/MiniPC の古めの containerd がそれでno match for platform in manifestと誤判定して pull に失敗する (ImagePullBackOff)。attestation を切ることで single-platform manifest だけになり pull が通る
2-4. ghcr ログイン (初回のみ、ユーザー実行)
ghcr.io は Docker のパスワード認証を受け付けない (MFA の有無に関わらず PAT 必須)。既存の gh CLI 認証に write:packages / read:packages を後付けして、gh auth token の出力で docker login するのが最短。
# 現状の scope 確認 (write:packages が無ければ次のコマンドで追加)
gh auth status
# scope を後付け (ブラウザで one-time code の入力を求められる)
gh auth refresh --scopes write:packages,read:packages
# gh のトークンで ghcr にログイン
gh auth token | docker login ghcr.io -u shuntaka9576 --password-stdin
# → "Login Succeeded" が出れば OK
トラブルシュート: docker login ghcr.io で対話的に password を入力すると denied: denied になる。これは GitHub の web パスワードを渡しているため。必ず PAT (もしくは gh auth token の出力) を stdin から渡すこと。
2-5. Image ビルド + push (ユーザー実行)
Apple Silicon Mac (native arm64) から実行するのが速い。
cd cluster/manifests/plamo-embedding
./build-and-push.sh
# → ghcr.io/shuntaka9576/plamo-embedding:latest を build & push
# タグを分けたい場合: TAG=2026-07-15 ./build-and-push.sh
初回 build は torch install + model download で 10-20 分程度。
なぜ workflow_dispatch action ではないか: PLaMo image は「Python deps か server.py を変えるまで更新しない」性質で、更新頻度が低い。deploy-tidb-proxy.yaml のような頻繁な更新は想定しないため、まずローカルスクリプトで済ませる。更新頻度が上がってきたら (~ 月次) workflow 化を検討する。
2-6. Package を public 化 (初回のみ、ユーザー実行)
ghcr は push した package が必ず private で作成される (リポジトリが public でも独立)。private のままだと kubelet が pull できず ErrImagePull になる。無料運用のため public に切り替える。
個人アカウントの package visibility は REST API から変更できない (org packages 用の PATCH はあるが user packages 側は 404)。Web UI からのみ操作可能。
open "https://github.com/users/shuntaka9576/packages/container/plamo-embedding/settings"
ページ末尾 Danger Zone → "Change package visibility" → Public → package name (plamo-embedding) を入力して確定。
確認 (API では visibility 取得は可能):
gh api /user/packages/container/plamo-embedding | jq '.visibility'
# → "public"
一度 public 化すれば同名 package への以降の push でも visibility は維持される。将来別 image (例: tidb-embedder) を追加する場合はそれぞれ改めて public 化が必要。
2-7. 反映 (ユーザー実行)
kubectl apply -f cluster/manifests/plamo-embedding/deployment.yaml
kubectl apply -f cluster/manifests/tailscale/plamo-embedding-public.yaml
kubectl -n plamo-embedding get pods -w
# node2 / node3 の2 Podが Running / READY 1/1 になるまで待つ
2-8. 動作確認 (ユーザー実行)
Tailnet公開Serviceへ接続する。
curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"日本語の検索テスト","mode":"query"}' | jq '.dim'
# → 2048 が返れば OK。異なる値なら Phase 3 の VECTOR(N) を実測値に合わせる
document モードも一応叩いておく:
curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"これは記事本文の例です","mode":"document"}' | jq '{dim, head: .vector[0:3]}'
Phase 2 完了条件
[x]
plamo-embeddingPod が Ready[x]
/embed(query / document 両方) が 2048 (実測値) 次元の float 配列を返す[x]
dim = 2048を Phase 3 の DDL に反映する値として記録した
Phase 3: articles.embedding 列 + TiFlash replica (旧方式・実施記録)
このフェーズでは1記事の本文全体を1vectorにしたが、長文でタイトルや検索対象の話題が薄まり、PLaMOの最大context長4096 tokensを超える部分はtruncationされる。2026-07-15の精度確認を受け、Phase 4から article_embedding_chunks へ移行する。以下はTiFlash障害を含む実施記録として残し、新規環境のschemaには articles.embedding を追加しない。
3-1. 当初適用したDDL (履歴)
tools/dsql-cli/dsl-tidb/schema/04_articles.sql の末尾に、他のマイグレーションと同じ形式で追記する (専用ファイルを切らないのは既存の content_html 追加も同ファイル末尾に置いているため)。
-- 2026-07-15 Vector 検索: articles.embedding + TiFlash replica
-- N は PLaMo Embedding 1B の実測次元 (Phase 2-8 で確認した値 = 2048)
ALTER TABLE `${SCHEMA}`.`articles`
ADD COLUMN `embedding` VECTOR(2048) NULL AFTER `content_html`;
-- TiFlash replica を張る (Phase 1 で TiFlash store が起動済みであること)
ALTER TABLE `${SCHEMA}`.`articles` SET TIFLASH REPLICA 1;
-- HNSW index は embedding の backfill と TiFlash COMPACT の完了後に作成する。
3-2. 適用 (ユーザー実行)
load.sh は差分マイグレーションではなく、schema/*.sql を先頭から実行する初期構築用スクリプトである。既存の blog_dev では、適用済みのカラムやインデックスを再追加しようとしてエラーになるため、今回は新しい2文だけを実行する。
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev <<'SQL'
ALTER TABLE articles ADD COLUMN embedding VECTOR(2048) NULL AFTER content_html;
ALTER TABLE articles SET TIFLASH REPLICA 1;
SQL
このDDLは旧方式であり、現在の 04_articles.sql からは削除済み。新規環境では実行しない。
cd tools/dsql-cli/dsl-tidb
./load.sh --database blog_dev --host tidb.${TAILNET}
3-3. 動作確認 (ユーザー実行)
TiFlash replica の同期が完了しているか確認。
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_SCHEMA = 'blog_dev' AND TABLE_NAME = 'articles';"
# AVAILABLE = 1, PROGRESS = 1 になれば同期完了 (数分〜十数分)
Phase 3 完了条件
[x]
articles.embedding列がvector(2048)で存在[x]
TIFLASH_REPLICA.AVAILABLE = 1, PROGRESS = 1
3-4. HNSW先行作成によるTiFlashクラッシュと復旧 (2026-07-15)
初回は全133記事の embedding が NULL の状態で HNSW index を作成した。その後、TiFlash v8.5.7 が DMFileVectorIndexWriter で既存DMFileのindexを構築する際、checksum frame size 0を除算して Floating point exception (exit 136) でCrashLoopした。
EnsureStableLocalIndex - Begin building index
Received signal Floating point exception(8).
Integer divide by zero.
FramedChecksumReadBuffer<XXH3>::doSeek
DMFileVectorIndexWriter::buildIndexForFile
TiDB側のindex定義はdrop済みだが、TiFlashはPVC上に残った古いlocal-index taskをschema同期より先に再開し、同じクラッシュを繰り返した。SHOW CREATE TABLE にindexがないことを確認してから、一度TiFlash replicaを0にしてlocal replicaを切り離す。以下はユーザーが実行する。
mysql -h tidb.${TAILNET} -P 4000 -u root -e \
"SHOW CREATE TABLE blog_dev.articles\G"
# idx_articles_embedding がないこと
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev -e \
"ALTER TABLE articles SET TIFLASH REPLICA 0;"
mysql -h tidb.${TAILNET} -P 4000 -u root -e "
SELECT * FROM INFORMATION_SCHEMA.TIFLASH_REPLICA
WHERE TABLE_SCHEMA = 'blog_dev' AND TABLE_NAME = 'articles';"
# 0 rowsになること
# CrashLoopのbackoffを待たず、replica=0の最新schemaで起動し直す
kubectl -n tidb-cluster delete pod basic-tiflash-0
kubectl -n tidb-cluster get pods -l app.kubernetes.io/component=tiflash -w
# basic-tiflash-0 が 4/4 Running に戻ること
TiDB公式のTiFlash replica作成手順でも SET TIFLASH REPLICA 0 はreplicaの削除を意味する。これだけで復旧しない場合は、壊れた分析用replicaのPVCを再作成する。元データはTiKVにあるため、backfill後にreplicaを1へ戻すと再同期される。
# TiFlashだけを停止する (TiDB / TiKVには影響しない)
kubectl -n tidb-cluster patch tidbcluster basic --type merge \
-p '{"spec":{"tiflash":{"replicas":0}}}'
kubectl -n tidb-cluster wait --for=delete pod/basic-tiflash-0 --timeout=5m
# 現在の分析用replica PVCと、Retainされるnode上の実データを削除する
PV=$(kubectl -n tidb-cluster get pvc data0-basic-tiflash-0 \
-o jsonpath='{.spec.volumeName}')
PV_NODE=$(kubectl get pv "${PV}" \
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}')
PV_PATH=$(kubectl get pv "${PV}" -o jsonpath='{.spec.hostPath.path}')
kubectl -n tidb-cluster delete pvc data0-basic-tiflash-0
kubectl -n tidb-cluster wait \
--for=delete pvc/data0-basic-tiflash-0 --timeout=5m
# pvReclaimPolicy=Retainのため、対象pathを表示・確認してから削除する
echo "${PV_NODE}:${PV_PATH}"
# /opt/local-path-provisioner/*_tidb-cluster_data0-basic-tiflash-0 であること
ssh "${PV_NODE}" sudo rm -rf --one-file-system -- "${PV_PATH}"
kubectl delete pv "${PV}" --wait=false
kubectl wait --for=delete "pv/${PV}" --timeout=5m
# source manifestと同じ1 replicaへ戻す
kubectl -n tidb-cluster patch tidbcluster basic --type merge \
-p '{"spec":{"tiflash":{"replicas":1}}}'
kubectl -n tidb-cluster get pods -l app.kubernetes.io/component=tiflash -w
# 新しいbasic-tiflash-0が4/4 Runningになること
PVCを先に削除してTerminatingになった場合
Podを停止する前にPVCの削除を開始すると、kubernetes.io/pvc-protection finalizerにより、PodがPVCを使用している間は削除が完了しない。2026-07-15の作業では以下の中途状態になった。
data0-basic-tiflash-0:Terminatingbasic-tiflash-0: 古いPVCを使用したまま3/4 CrashLoopBackOffTiDB:
REPLICA_COUNT = 1のまま (SET TIFLASH REPLICA 0のDDL履歴なし)PV:
pvc-01d5b048-5bd0-4348-9bcb-b4f8bfd73d35, reclaim policyはRetain
この場合はfinalizerを強制削除せず、次の順序で復旧を続ける。
# 削除中PVCのPV、node、hostPathをPVCが消える前に記録する
PV=$(kubectl -n tidb-cluster get pvc data0-basic-tiflash-0 \
-o jsonpath='{.spec.volumeName}')
PV_NODE=$(kubectl get pv "${PV}" \
-o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}')
PV_PATH=$(kubectl get pv "${PV}" -o jsonpath='{.spec.hostPath.path}')
# DB上のTiFlash replicaを外す
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev -e \
"ALTER TABLE articles SET TIFLASH REPLICA 0;"
# TiFlash Podを停止するとPVC protectionが解除され、PVC/PVの削除が進む
kubectl -n tidb-cluster patch tidbcluster basic --type merge \
-p '{"spec":{"tiflash":{"replicas":0}}}'
kubectl -n tidb-cluster wait --for=delete pod/basic-tiflash-0 --timeout=5m
kubectl -n tidb-cluster wait \
--for=delete pvc/data0-basic-tiflash-0 --timeout=5m
# pvReclaimPolicy=Retainのため、旧データとReleased PVを明示削除する
if kubectl get pv "${PV}" >/dev/null 2>&1; then
echo "${PV_NODE}:${PV_PATH}"
# /opt/local-path-provisioner/*_tidb-cluster_data0-basic-tiflash-0 であること
ssh "${PV_NODE}" sudo rm -rf --one-file-system -- "${PV_PATH}"
kubectl delete pv "${PV}" --wait=false
kubectl wait --for=delete "pv/${PV}" --timeout=5m
fi
# 空のPVCでTiFlashを再作成する
kubectl -n tidb-cluster patch tidbcluster basic --type merge \
-p '{"spec":{"tiflash":{"replicas":1}}}'
kubectl -n tidb-cluster get pods -l app.kubernetes.io/component=tiflash -w
# 新しいPVC名になり、basic-tiflash-0が4/4 Runningを維持すること
旧 articles.embedding のHNSW indexは、Phase 4のchunk方式を検証してから削除する。それまでは比較・rollback用として残す。
Phase 4: PLaMO tokenizerによるchunk backfill + HNSW作成
4-1. chunk設計 (実装済み)
PLaMO-Embedding-1B公式model cardでは最大context長は4096 tokensだが、学習時のcontext長とbenchmark計測は1024 tokens。これに合わせ、文字数近似ではなくPLaMO Pod内の同じtokenizerで分割する。
MarkdownのATX見出し (
#〜######) をsection境界にし、見出し階層を保持するcode fence内の
#は見出しとして扱わない1 chunkはmetadataを含め最大1024 tokens、長いsectionは128 tokens overlapでwindow分割する
各embedding入力は
タイトル + 見出し階層 + 概要 + 本文chunkとするmetadataは最大256 tokensに制限し、本文用budgetを確保する
source_hashはchunking version、token設定、title、description、contentからSHA-256で作る
PLaMO Serviceへ POST /chunks を追加した。ここでtokenizeだけ行い、返された embedding_text を既存の POST /embed (mode=document) へ渡す。
cluster/manifests/plamo-embedding/
├── chunking.py
├── chunking_test.py
├── server.py
└── Dockerfile
4-2. PLaMO Service更新 (ユーザー実行)
cd cluster/manifests/plamo-embedding
./build-and-push.sh
kubectl -n plamo-embedding rollout restart deployment/plamo-embedding
kubectl -n plamo-embedding rollout status deployment/plamo-embedding --timeout=15m
/chunks がversion、token数、embedding用textを返すことを確認する。
curl -s -X POST http://plamo-embedding.${TAILNET}/chunks \
-H 'Content-Type: application/json' \
-d '{
"title":"TiDB Vector検索",
"description":"動作確認用の記事",
"content":"# 概要\nPLaMOで記事を分割します。",
"max_tokens":1024,
"overlap_tokens":128
}' | jq '{version, max_tokens, overlap_tokens, chunks}'
4-3. chunkテーブル作成 (ユーザー実行)
09_article_embedding_chunks.sql を追加した。既存の blog_dev には過去の非冪等な ALTER TABLE が適用済みなので load.sh は再実行せず、今回のファイルだけを流す。
cd tools/dsql-cli/dsl-tidb
SCHEMA=blog_dev
sed "s|\${SCHEMA}|${SCHEMA}|g" schema/09_article_embedding_chunks.sql \
| mysql -h tidb.${TAILNET} -P 4000 -u root
CREATE TABLE article_embedding_chunks (
chunk_id CHAR(36) NOT NULL DEFAULT (UUID()),
article_id CHAR(36) NOT NULL,
chunk_index INT UNSIGNED NOT NULL,
heading VARCHAR(1000) NULL,
content LONGTEXT NOT NULL,
token_count INT UNSIGNED NOT NULL,
chunking_version VARCHAR(64) NOT NULL,
source_hash CHAR(64) NOT NULL,
embedding VECTOR(2048) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (chunk_id),
UNIQUE KEY uq_article_embedding_chunks_article_index (article_id, chunk_index)
);
ALTER TABLE article_embedding_chunks SET TIFLASH REPLICA 1;
HNSW indexはbackfillとTiFlash compactionの後に作る。embedding は最初から NOT NULL でinsertするため、旧方式の「全NULLのDMFileへindexを先行作成」した状態にはならない。
4-4. tools/tidb-embedder (実装済み)
CLIは全記事を読み、保存済み source_hash と現在の記事内容から計算したhashが違う記事だけを対象にする。
--dry-runは/chunksまで実行し、chunk数とtoken数を表示する。embedding生成とDB更新は行わない本実行は全chunkの2048次元vector生成が成功してから、記事単位のtransactionで旧chunkを削除・新chunkをinsertする
途中のAPI失敗では既存chunkを保持し、終了コードを非0にする
--allでhash一致記事も再生成できる全件実行時は削除済み記事の孤立chunkも検出し、本実行で削除する
リクエストごとにHTTP connectionを閉じ、2 Podへconnection単位で分散する
--endpoint <url> TiDB接続先
--embed-endpoint <url> PLaMO Service
--all source hash一致記事も再生成
--slug <slug> 特定slugだけ処理
--dry-run chunk数/token数だけ確認
--concurrency <n> 同時embedding request数 (default: 1、2 Pod時は2)
--timeout <ms> PLaMO API 1 requestのtimeout (default: 120000)
--max-tokens <n> 1 chunkの最大token数 (default: 1024)
--overlap-tokens <n> window間の重複token数 (default: 128)
4-5. backfill (ユーザー実行)
Tailnet公開Service経由でnode2/node3の2 Podへ分散する。kubectl port-forward svc/... は1 Podへ直接転送されるため使用しない。
cd tools/tidb-embedder
bun install
bun run backfill \
--endpoint "mysql://root@tidb.${TAILNET}:4000/blog_dev" \
--embed-endpoint "http://plamo-embedding.${TAILNET}" \
--concurrency 2 \
--max-tokens 1024 \
--overlap-tokens 128 \
--dry-run
# chunk数と最大token数を確認後、--dry-runを外す
bun run backfill \
--endpoint "mysql://root@tidb.${TAILNET}:4000/blog_dev" \
--embed-endpoint "http://plamo-embedding.${TAILNET}" \
--concurrency 2 \
--max-tokens 1024 \
--overlap-tokens 128
PLaMOは1リクエストでも約14/16 logical CPUを使用した。2 Podに対して --concurrency 2 (1 Podあたり1リクエスト目安) とし、それ以上はCPU oversubscriptionで逆に遅くなるため増やさない。
2 Pod backfill時のCPU使用率 (2026-07-15、旧1記事1vector時)

17:23頃まではnode3の単一Podが約90%のCPUを継続使用していた。2 Pod化した17:41頃以降はnode2/node3の両方に負荷が分散し、TiFlashを配置するnode1は約2〜3%を維持した。chunk方式はrequest数が増えるが、同じく2 Pod、同時実行2を上限にする。
4-6. backfillとHNSWの確認 (ユーザー実行)
全記事にchunkがあり、token上限違反が0件であることを確認する。
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev -e "
SELECT COUNT(*) AS chunks,
COUNT(DISTINCT article_id) AS chunked_articles,
MIN(token_count) AS min_tokens,
MAX(token_count) AS max_tokens,
SUM(token_count > 1024) AS over_limit
FROM article_embedding_chunks;
SELECT COUNT(*) AS missing_articles
FROM articles AS a
LEFT JOIN article_embedding_chunks AS c ON c.article_id = a.article_id
WHERE c.chunk_id IS NULL;"
chunked_articles = 133、missing_articles = 0、over_limit = 0を確認する。TiFlash replica同期後にcompactし、HNSW indexを作る。
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_SCHEMA = 'blog_dev'
AND TABLE_NAME = 'article_embedding_chunks';"
# AVAILABLE = 1, PROGRESS = 1まで待つ
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev <<'SQL'
ALTER TABLE article_embedding_chunks COMPACT;
CREATE VECTOR INDEX idx_article_embedding_chunks_embedding
ON article_embedding_chunks ((VEC_COSINE_DISTANCE(embedding))) USING HNSW;
SQL
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 = 'blog_dev'
AND TIDB_TABLE = 'article_embedding_chunks';"
ROWS_STABLE_NOT_INDEXED = 0、ROWS_DELTA_NOT_INDEXED = 0、ERROR_MESSAGE が空ならbuild完了。
2026-07-15の実行結果:
133記事から1,101 chunksを生成 (
missing_articles = 0)token数は最小20、最大1024、1024超過は0件
HNSWは
ROWS_STABLE_INDEXED = 1101、Stable/Deltaの未index行はともに0、errorなし
4-7. サンプル検索と旧vector削除 (ユーザー実行)
QVEC=$(curl -s -X POST http://plamo-embedding.${TAILNET}/embed \
-H 'Content-Type: application/json' \
-d '{"text":"Rust Axum API","mode":"query"}' \
| jq -c '.vector')
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev -e "
SELECT c.article_id, LEFT(a.title, 55) AS title, c.heading,
VEC_COSINE_DISTANCE(c.embedding, '${QVEC}') AS dist
FROM article_embedding_chunks AS c
JOIN articles AS a ON a.article_id = c.article_id
ORDER BY VEC_COSINE_DISTANCE(c.embedding, '${QVEC}')
LIMIT 20;"
2026-07-15に Rust Axum API で確認したところ、旧1記事1vector方式では対象記事のdistanceが 0.4674 で上位に入らなかったのに対し、chunk方式では「Rust(axum) on Lambda × Aurora DSQL × Next.js on Vercelで個人ブログをリーアーキした話」が1位 (0.35846897784588017) になった。
このqueryはタイトルとの一致が強いため、同じ記事の複数chunkが上位20件に現れる。chunk近傍検索としては想定どおりであり、Phase 5の検索APIでは候補を多めに取得した後、article_id ごとにdistance最小のchunkへ集約する。
HNSW利用可否はjoinや記事単位の集約を外し、TiFlashを固定した最小queryで確認する。
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev -e "
EXPLAIN
SELECT /*+ READ_FROM_STORAGE(TIFLASH[c]) */
c.article_id,
VEC_COSINE_DISTANCE(c.embedding, '${QVEC}') AS dist
FROM article_embedding_chunks AS c
ORDER BY VEC_COSINE_DISTANCE(c.embedding, '${QVEC}')
LIMIT 20;"
TableFullScan の operator info に index:idx_article_embedding_chunks_embedding と annIndex:COSINE があれば利用可能。件数が小さい間、hintなしではoptimizerがTiKV全走査を選ぶことがある。
2026-07-15の実行計画では、TiFlash MPPへTopN (count:20) がpushdownされ、chunk用HNSW indexを使う annIndex:COSINE を確認できた。vector部分は2048次元のため省略表記。
TableReader_24 MppVersion: 2, data:ExchangeSender_23
└─ExchangeSender_23 mpp[tiflash] ExchangeType: PassThrough
└─TopN_22 mpp[tiflash] offset:0, count:20
└─TableFullScan_21 mpp[tiflash] table:c,
index:idx_article_embedding_chunks_embedding(embedding)
keep order:false, annIndex:COSINE(embedding..[...], limit:20)
chunk検索の精度を確認できた後、比較・rollback用に残していた旧vectorを削除する。
mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev <<'SQL'
ALTER TABLE articles DROP INDEX idx_articles_embedding;
ALTER TABLE articles SET TIFLASH REPLICA 0;
ALTER TABLE articles DROP COLUMN embedding;
SQL
Phase 4 完了条件
[x] 全133記事に1件以上のchunkがあり、1024 tokens超過が0件
[x] TiFlashが
4/4 Runningのままchunk HNSW buildを完了し、未index行が0件[x]
Rust Axum APIなど旧方式で弱かったqueryの順位が改善する[x] TiFlash固定の
EXPLAINにannIndex:COSINEが現れる[x] 検証後に旧
articles.embedding、HNSW、TiFlash replicaを削除する
Phase 5: blog-api 検索エンドポイント + apps/web 検索 UI
5-1. 全体設計
API:
GET /users/{name}/articles/search?q=<query>&tags=<a,b>&mode=and|or&limit=20認証: 既存の公開記事一覧と同じく認証不要。
status = 'published'の記事だけを返すハンドラフロー:
qを PLaMo Embedding Service で vectorize (mode=query)article_embedding_chunksから近傍chunkをlimit * 10件取得するarticle_idごとに最小distanceのchunkを残し、記事の公開条件+タグ絞り込み条件を適用する検索用DTOに
distance(cosine distance) を付けて返す
UI コンセプト: 既存
FloatingTagFilterを検索・タグ統合フローティングへ拡張。意味検索とタグ絞り込みを1つのピル + 1つのpanelにまとめ、検索入力・類似度付きpreview・タグtreeを同時に見せる
5-2. API 実装ポイント
infrastructure/src/embedding/client.rsを新規作成し、PLAMO_EMBED_ENDPOINTを環境変数から読んでreqwestでPOST /embedを叩くclientを置く (query / document両方に対応。Phase 7でも同じものを流用する)kernel/src/repository/users_articles.rsにsearch_published_by_user_name(name, vector, tag_filter, candidate_limit, limit)メソッドを追加adapter/src/repository/users_articles.rsでSQL実装:WITH nearest_chunks AS ( SELECT article_id, heading, content, VEC_COSINE_DISTANCE(embedding, ?) AS dist FROM article_embedding_chunks ORDER BY VEC_COSINE_DISTANCE(embedding, ?) LIMIT ? -- API limit * 10 (tags 併用時は絞り込みで欠けるので * 30 まで拡張してよい) ), best_chunk_per_article AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY article_id ORDER BY dist) AS rn FROM nearest_chunks ) SELECT a.article_id, a.title, a.slug, a.description, a.thumbnail, a.type, a.published_at, c.dist FROM best_chunk_per_article AS c JOIN articles AS a ON a.article_id = c.article_id WHERE c.rn = 1 AND a.user_id = ? AND a.status = 'published' AND ( ? = 0 -- tags 未指定なら常に TRUE OR a.article_id IN ( SELECT article_id FROM articles_tags WHERE tag_path IN (?) GROUP BY article_id HAVING COUNT(DISTINCT tag_path) = ? -- AND: 選択タグ数と一致 / OR: 1 以上 ) ) ORDER BY c.dist LIMIT ?
inner queryのTopNがHNSWへpushdownされるか、実データで
EXPLAINを確認する同じ記事の複数chunkが候補に入るため、window関数で記事単位にdeduplicateする
タグ絞り込みは HNSW 後の post-filter。restrictive なタグ組み合わせで結果が空になる場合は inner LIMIT を上げる (
limit * 30あたりまで)
api/src/route/users_articles.rsに/searchサブルート追加api/src/handler/users_articles.rsでembedding clientを呼び、tags/modeクエリを受けてrepositoryへ渡す。qは空文字不可・500文字以内、limitは1 - 100件に制限するレスポンスDTO:
ArticleSearchResultResponseにdistance: f64を持たせ、通常の記事一覧DTOへ検索固有フィールドを混ぜないタグなしは
limit * 10、タグありはpost-filterで候補が減るためlimit * 30のchunkをANN候補にする (最大3000件)PLAMO_EMBED_ENDPOINTが未設定でもAPI自体は起動し、検索endpointだけ503 Service Unavailableを返す。PLaMO呼び出し失敗は502 Bad Gatewayを返す
5-3. UI 実装ポイント (apps/web)
新規コンポーネント (すべて src/components/ 配下。Story 必須):
ファイル |
役割 |
|---|---|
|
既存 |
|
検索入力欄 (debounce 300ms、× でクリア) |
|
類似度バー ( |
拡張:
SearchProviderをTagFilterProviderの内側へ追加し、query/submittedQuery/searchResultsとdebounce・request cancelを管理するTagFilterProviderのURL更新時は検索queryを保持し、検索とタグが互いのquery parameterを消さないようにするlib/api.tsにsearchArticles(userName, { q, tags, mode, limit, signal })を追加ActiveTagBarに検索クエリチップを追加表示 (× で解除)ArticleCardにdistance?: numberprop を追加 (指定時は右端にSimilarityMeterを表示)FilteredArticleListはquery || tags.length > 0を絞り込み条件に拡張
ピル形状 (下端中央、既存 FloatingTagFilter と同じ位置):
┌──────────────────┐
│ search tag 3 │ 検索とタグを一体化した1ピル
└──────────────────┘
クリックで検索 + タグの統合panel全体を開閉する
filtering || queryのときはピル全体を--color-text、それ以外は--color-text-muted
統合panel:
┌──────────────────────────────────────┐
│ [ 気になることを日本語で… ] × │ ← SearchInput
├──────────────────────────────────────┤
│ # tech/rust × AND │ ← 選択中タグチップ (存在時のみ)
├──────────────────────────────────────┤
│ TiDBのFTS Kuromoji調査 92%▮▮▮│ ← 上位 3 件のプレビュー
│ Vector検索 実装ノート 87%▮▮ │ クリックで記事へ直遷移
│ PLaMo Embedding 触ってみる 83%▮▮ │
│ │
│ ─────── 全 12 件を一覧で見る → ─── │ ← main list を検索結果に差し替え
├──────────────────────────────────────┤
│ tech/ │
│ rust (12) │ ← 既存 TagFilterTree
│ aws/ (24) │
└──────────────────────────────────────┘
URL 同期: ?q=xxx&tags=tech/rust&mode=and (既存 pushFilterUrl を拡張)
併用時の挙動:
qとtagsは AND で併用。ActiveTagBar は「search: "xxx" × / #tech/rust × / AND / 12件 / クリア」の順で表示一覧本体は
FilteredArticleListがsearchResultsを優先表示。各ArticleCardはdistanceを持つので類似度メーターを右端に付ける検索結果 0 件時は「AND を OR に切り替える」ではなく「検索クエリを外す」ボタンを見せる (ミスヒット時に一撃で戻れる)
検索inputは300ms debounceし、Enterでは即時確定する。新しい検索やタグ変更時は直前requestを
AbortControllerでcancelするEscapeはinputに値がある場合は1回目で検索を解除し、空の状態でもう一度押すとpanelを閉じる
5-4. 動作確認 (ユーザー実行)
# blog-api ローカル起動 (別ターミナル)
cd apps/blog-api
PLAMO_EMBED_ENDPOINT=http://plamo-embedding.${TAILNET} \
DATABASE_URL="mysql://root@tidb.${TAILNET}:4000/blog_dev" \
PORT=43003 cargo run --bin server
# API 単体
curl -sG "http://localhost:43003/users/<user_name>/articles/search" \
--data-urlencode 'q=Rust Axum API' --data-urlencode 'limit=5' | jq '.'
curl -sG "http://localhost:43003/users/<user_name>/articles/search" \
--data-urlencode 'q=Rust Axum API' \
--data-urlencode 'tags=tech/rust,tech/aws/lambda' \
--data-urlencode 'mode=and' --data-urlencode 'limit=5' | jq '.'
curl -sG "http://localhost:43003/users/<user_name>/articles/search" \
--data-urlencode 'q=Rust Axum API' \
--data-urlencode 'tags=tech/rust,tech/aws/lambda' \
--data-urlencode 'mode=or' --data-urlencode 'limit=5' | jq '.'
# UI (Storybook で先に mock 確認)
cd apps/web
bun run storybook
# → Components/FloatingSearchTagFilter / SearchInput / SimilarityMeter
# UI (実 API 接続)
bun run dev
# http://localhost:43000/ でfloating pillをクリック
2026-07-15に blog_dev とPLaMOを使って確認した結果:
q=Rust Axum API&limit=5: 5件。先頭は「Rust(axum) on Lambda × Aurora DSQL × Next.js on Vercelで個人ブログをリーアーキした話」上記query +
tags=tech/rust,tech/aws/lambda&mode=and: 両方のタグを持つ上記記事だけ1件上記query + 同じtags +
mode=or: 片方以上のタグを持つ5件PLaMOのquery vector生成、TiDB検索、タグ取得を含むAPI全体の応答時間は約0.8秒 (ローカルからTailnet経由)
PLAMO_EMBED_ENDPOINT未設定時は/healthが204のまま、検索endpointだけ503になる
5-5. EXPLAIN 確認
EXPLAIN
SELECT article_id, VEC_COSINE_DISTANCE(embedding, '[...]') AS dist
FROM article_embedding_chunks
ORDER BY VEC_COSINE_DISTANCE(embedding, '[...]') ASC
LIMIT 200;
TableFullScanのoperator infoにannIndex:COSINEがあること (HNSW index利用時もexecutor名はTableFullScan)効かない場合: chunkテーブルのTiFlash replica同期 (Phase 4) とHNSW build完了を確認
tags 併用時も inner query (HNSW pushdown) が先に評価され、外側の JOIN + tag subquery が post-filter になっていることを確認
2026-07-15にタグAND条件を含む実装SQLで確認した。nearest_chunks LIMIT 600 がTiFlash MPPへpushdownされ、次のoperatorが出力された。
TableFullScan_177 mpp[tiflash]
table:c, index:idx_article_embedding_chunks_embedding(embedding)
keep order:false, annIndex:COSINE(embedding..[...], limit:600)
Phase 5 完了条件
[x] 検索エンドポイントが 200 を返す (q のみ / q + tags AND / q + tags OR の 3 パターン)
[ ] 上位 20 件が意味的に妥当
[x]
EXPLAINで HNSW / TiFlash 経路が使われている[x] 未認証で他人の下書きが返らないこと (
status = 'published'を検索SQLのpost-filterで強制)[x] UI: floating pillから検索 + タグの統合panelを開ける
[x] UI: 検索クエリ入力で上位3件がpanel内に類似度メーター付きで即previewされる
[x] UI: 「全 N 件を一覧で見る」でmain listが検索結果に差し替わる
[x] UI: タグ選択と検索クエリのAND併用が動作する (
ActiveTagBarに両方chipが出る)[x] UI: URLに
q/tags/modeが同期する (直link・戻る/進むで復元)[x] UI: Storybookに
SimilarityMeter/SearchInput/FloatingSearchTagFilterのStoryがある
Phase 6: 本番 (blog_prd) 適用
6-1. 事前バックアップ
docs/source/98_tasks/2026-07-05-tidb-prd-dump/index.md の手順で blog_prd を論理ダンプ。
6-2. TiFlash store確認 (本番)
Phase 1でclusterレベルのTiFlash追加は完了しているため、本番でも同じstoreを共有できる。article_embedding_chunks のreplica設定は6-3のDDLに含まれる。
kubectl -n tidb-cluster get pods -l app.kubernetes.io/component=tiflash
# basic-tiflash-0 が4/4 Runningであること
6-3. DDL 適用 (本番)
既存 blog_prd でも load.sh は再実行せず、追加ファイルだけ適用する。
cd tools/dsql-cli/dsl-tidb
SCHEMA=blog_prd
sed "s|\${SCHEMA}|${SCHEMA}|g" schema/09_article_embedding_chunks.sql \
| mysql -h tidb.${TAILNET} -P 4000 -u root
6-4. 埋め戻し (本番)
ユーザーが手元から実行 (Tailnet 経由で prd DB と PLaMo Pod の両方に到達可能)。
cd tools/tidb-embedder
bun run backfill \
--endpoint "mysql://root@tidb.${TAILNET}:4000/blog_prd" \
--embed-endpoint "http://plamo-embedding.${TAILNET}" \
--concurrency 2 \
--dry-run
# 件数確認後に --dry-run を外して本実行
全記事のbackfill完了後にcompactとHNSW index作成を行う。
mysql -h tidb.${TAILNET} -P 4000 -u root blog_prd <<'SQL'
ALTER TABLE article_embedding_chunks COMPACT;
CREATE VECTOR INDEX idx_article_embedding_chunks_embedding
ON article_embedding_chunks ((VEC_COSINE_DISTANCE(embedding))) USING HNSW;
SQL
6-5. 本番 blog-api → PLaMo Service の到達経路 (案 1: tidb-proxy に L4 中継を増設)
本番 blog-api は AWS Lambda 上で動き、TiDB へは tidb-proxy (ECS Fargate + Tailscale tsnet) 経由で TCP 中継している。PLaMo Embedding Service は Tailnet 上の plamo-embedding.<tailnet> (HTTP) にしか存在せず、Lambda から直接は到達できない。
方式選定
Tailnet 到達性は tidb-proxy コンテナ内の forwarder プロセスの tsnet.Server にしか無い。squid は同じ VPC 上の別プロセスで Tailnet ノードではないため、plamo-embedding.<tailnet> の MagicDNS 名を解決も WireGuard ルーティングもできない (squid.conf も CONNECT 443 のみ許可で、PLaMo の平文 HTTP:80 は通らない)。したがって:
案 2 (squid で中継): squid を Tailnet ノードにできないため不可。成立するのは PLaMo を Funnel 等で公開 HTTPS にする場合のみで、private 推論サービスをインターネットに晒すことになるので却下
案 3 (cluster 内に検索サービスを別建て): blog-api の公開判定・認可 policy を二重実装し read-path が 2 ランタイムに割れる。Lambda を Tailnet から完全に外す強い理由がある時のみ検討
案 1 (forwarder に PLaMo 用ポートを増設):
plamo-embedding.<tailnet>はtidb.<tailnet>と同じ Tailscale operator LoadBalancer で公開されており、既存tag:proxy → tag:k8sACL がそのまま効く。HTTP は TCP ストリームなので L4 転送でそのまま流せる。既存パターンの素直な拡張で追加の攻撃面もゼロ (VPC 内 ENI からのみ到達可)
→ 案 1 を採用する。
確認済みの事実 (2026-07-15, tailscale status --json)
デバイス |
HostName |
タグ |
|---|---|---|
PLaMo Service |
|
|
TiDB |
|
|
forwarder (tidb-proxy) |
|
|
Tailnet suffix:
tailea8e2.ts.netTiDB は本番で既に
tag:proxy → tidb.<tailnet>:4000に到達できている。PLaMo も同じtag:k8sなので、残る論点は既存 ACL がポート限定か全ポートか、と PLaMo の 80 が通るかだけ
(1) Go forwarder をマルチターゲット化 — apps/tidb-proxy/cmd/forwarder/main.go
現状は単一ターゲット (TIDB_HOSTNAME/TIDB_PORT → 0.0.0.0:13306 の 1 listener)。これを forward ルールのリストにし、同じ ts *tsnet.Server を共有したまま listener を 2 本立てる。PLaMo listener はオプション (PLAMO_HOSTNAME が空なら張らない = 後方互換)。
PLAMO_LISTEN_ADDR=0.0.0.0:18080
PLAMO_HOSTNAME=plamo-embedding
PLAMO_PORT=80
loadConfig()が[]forwardRule{ListenAddr, Target, UpstreamName}を返すようにし、ルールごとにnet.Listen→go runForwarder(ts, listener, rule.Target, tel, rule.UpstreamName)pre-warm dial も各 Target に対して回す
注意: telemetry の
upstreamNameは現状setupTelemetryで単一固定 (otel.go)。forwardConnが読むtel.upstreamNameをrunForwarderから渡す per-forward の名前 ("tidb"/"plamo-embedding") に差し替えて、span/metric で 2 経路を区別する
(2) ecspresso task def — iac/aws/ecspresso/tidb-proxy/ecs-task-def.jsonnet
environment: [
// ... 既存 ...
{ name: 'PLAMO_HOSTNAME', value: 'plamo-embedding' },
{ name: 'PLAMO_PORT', value: '80' },
{ name: 'PLAMO_LISTEN_ADDR', value: '0.0.0.0:18080' },
],
portMappings: [
{ containerPort: 13306, protocol: 'tcp' },
{ containerPort: 3128, protocol: 'tcp' },
{ containerPort: 18080, protocol: 'tcp' }, // 追加
],
healthCheck: {
command: ['CMD-SHELL',
'nc -z localhost 13306 && nc -z localhost 3128 && nc -z localhost 18080'],
// interval / timeout / retries / startPeriod は据え置き
},
(3) CDK: Lambda の SG と env — iac/aws/lib/api/blog-api-construct.ts
lambdaSg.addEgressRule(proxySecurityGroup, ec2.Port.tcp(18080), 'plamo embedding via tidb-proxy');
proxySecurityGroup.addIngressRule(
lambdaSg,
ec2.Port.tcp(18080),
`plamo from lambda-sg-${props.stageName}`,
);
// environment に追加
PLAMO_EMBED_ENDPOINT: `http://${proxyDnsName}:18080`, // = http://tidb-proxy.internal:18080
既存の NO_PROXY に tidb-proxy.internal が含まれるため、reqwest は PLAMO_EMBED_ENDPOINT を HTTP_PROXY (squid) に回さず 直で TCP 接続し、forwarder → tsnet に乗る。追加設定不要。
(4) Tailscale ACL — tag:k8s:80 を追加
ACL の正は 02_cluster.md の ACL 設定。現行は tag:proxy → tag:k8s:4000 (TiDB のみ)。PLaMo も同じ tag:k8s なので dst に tag:k8s:80 を足すだけ。https://login.tailscale.com/admin/acls で保存する (tagOwners は変更不要)。
{ "action": "accept", "src": ["tag:proxy"], "dst": ["tag:k8s:4000", "tag:k8s:80"] }
(5) blog-api embedding client
Phase 5-2 で作る reqwest クライアントが PLAMO_EMBED_ENDPOINT を読んで POST するだけなので コード変更不要。ローカルは http://plamo-embedding.<tailnet>、Lambda は http://tidb-proxy.internal:18080 と env が違うだけ。
(6) 検証 (デプロイ後)
ECS exec 内でも nc plamo-embedding.<tailnet> 80 は通らない (Tailnet 到達性は forwarder プロセス内の tsnet に閉じており、コンテナ OS の nc は MagicDNS を解決できない)。検証は 18080 listener 経由で行う。
TASK=$(aws ecs list-tasks --cluster tidb-proxy --query 'taskArns[0]' --output text)
aws ecs execute-command --cluster tidb-proxy --task "$TASK" \
--container tidb-proxy --interactive \
--command "wget -qO- --post-data '{\"text\":\"疎通確認\",\"mode\":\"query\"}' \
--header 'Content-Type: application/json' http://localhost:18080/embed | head -c 200"
# → vector 配列が返れば ACL + forwarder 経路ともに OK
落とし穴
HTTP_PROXY に吸われない条件: Lambda の
HTTP_PROXY/HTTPS_PROXYは squid (tidb-proxy.internal:3128) を指すが、PLaMo は同じtidb-proxy.internalの別ポートなので既存NO_PROXYで除外され squid を経由しない。embedding クライアント (infrastructure/src/embedding/client.rs:50) はClient::builder()(reqwest デフォルト = env proxy 有効かつNO_PROXY尊重、.proxy()/.no_proxy()未指定) で作るため、PLAMO_EMBED_ENDPOINTのホストをtidb-proxy.internalにしておけば追加設定は不要。別ホスト名にするならNO_PROXYへ追加する (squid は CONNECT 443 のみ許可で、除外し損ねると平文 POST がdeny allされる)Host ヘッダが
tidb-proxy.internal:18080になる。uvicorn / FastAPI はデフォルトで Host を検証しないので問題ないが、将来TrustedHostMiddlewareを入れたら許可が要るPod 分散: forwarder は接続ごとに
plamo-embedding.<tailnet>:80へ dial するので operator LB / Service が分散する。本番検索は 1 リクエストにつき embedding 1 回 (query のみ) で backfill ほどシビアでないタイムアウト: forwarder の 5s dial timeout は tsnet 接続確立まで。PLaMo 推論 (100–500ms) はストリーム転送側で forwarder は追加 timeout を持たない。実効タイムアウトは Lambda 側 reqwest で握る
Phase 6 完了条件
[ ]
blog_prd.article_embedding_chunksに全記事のchunkがある[ ]
TIFLASH_REPLICA.AVAILABLE = 1forblog_prd.article_embedding_chunks[ ]
idx_article_embedding_chunks_embeddingの未index行が0件[ ] forwarder に PLaMo 用 18080 listener を追加し、ECS exec の 18080 経由で
/embedが vector を返す[ ] Tailscale ACL tests に
tag:proxy → plamo-embedding:80が含まれ、保存できている[ ] Lambda の
PLAMO_EMBED_ENDPOINTがhttp://tidb-proxy.internal:18080を指し、本番検索 API が 200 を返す
Phase 7: GitHub webhook 経路への embedding 生成組み込み (継続更新)
Phase 4のローカルbackfillは、実行時点でsource hashが変わった記事のchunkを記事単位で差し替える。以降の記事追加・更新をwebhook契機で自動化するのが本フェーズ。
7-1. 変更方針
apps/blog-api/api/src/handler/webhooks.rs は content_html を「contentが変わったとき」に生成している。同じ条件を起点にchunkも再生成する。
title、description、contentのいずれかの変更でchunkを再生成する
Phase 5-2で作ったPLaMO clientを使い、
/chunksと/embedを呼ぶ全vector生成成功後、
article_embedding_chunksをtransactionで記事単位に差し替える本番の到達性 (Phase 6-5) が解決していない間は、環境変数
PLAMO_EMBED_ENDPOINTが未設定なら生成をスキップする実装にしておく (dev のみ有効化)
7-2. adapter/src/repository/articles.rs のSQL追加
記事upsertとは別transactionで、対象 article_id の既存chunkを削除し、新chunkをinsertするrepository methodを追加する。PLaMO呼び出し失敗時はこのtransactionを開始せず、既存chunkを残す。
7-3. handler/webhooks.rs の修正
content_html 生成の直後にchunk生成を追加する。PLaMO endpoint未設定または呼び出し失敗はログに出し、記事更新を成功させつつ既存chunkを保持する。
7-4. 動作確認
ローカルで
PLAMO_EMBED_ENDPOINT=http://plamo-embedding.${TAILNET}を渡して webhook を再送対象記事のtitle、description、contentを変更したコミットでsource hashとchunkが更新されること
endpoint 未設定でも記事更新自体は成功すること
Phase 7 完了条件
[x] webhookで記事が変わった場合、chunkが記事単位で差し替わる (dev。
blog_dev.article_embedding_chunksの created_at が webhook 処理ウィンドウ内で更新されることを確認)[ ] PLaMo endpoint 未設定 / 呼び出し失敗でも記事更新自体は成功する
[ ] PLaMO失敗時に既存chunkが削除されない
7-5. Followup: Lambda ループ内の tracing ログが CloudWatch に出ない
dev で webhook を再送して DB の chunk 差し替えは検証できたが、process_push_event のループ内側で発火する info!("Article upserted: ...") / info!("Article chunks replaced: ...") / warn!("Failed to chunk article ...") などが CloudWatch Logs に一切現れない。
観測結果 (2026-07-16, dev, requestId e4a4e306-ba9a-49b8-8413-1ca77108bf47):
ループ外側の
info!("Processing async push event")/info!("Found {} markdown files")/info!("Webhook processing complete: processed=2, succeeded=2, failed=0")は出るループ内側の info!/warn! は 0 件 (
aws logs filter-log-eventsでArticle,upserted,chunks,slug,keeping,Skipping,Failed,error,panicすべてマッチしない)同じ target (
api::handler::webhooks) / 同じレベル (INFO) / 同じ span (lambda.handler) なのに、ループの前後だけ通るDB (
article_embedding_chunks) は正しく差し替わっているので、code path は実行されているAthena (tidb-proxy squid_access) には OGP 用の外部 fetch が記録されており、markdown 変換まで含めてループが完走した傍証はある
推測される原因候補:
spawn_blockingから戻った直後の future の tracing context が壊れて event が subscribable でないLambda LWA の
response_streamモードで、レスポンス書き出しと非同期な stdout flush の間で内部ログが失われるtracing_opentelemetry layer が span を close する順序で fmt::layer への forwarding を妨げている
暫定対応案 (別 PR):
regenerate_chunksに#[tracing::instrument(skip_all, fields(slug, chunks))]を張り、event を span 属性化するループの各記事に per-article span を張って明示的に enter する
一部の重要 info!/warn! を
eprintln!に差し替えて fmt::layer をバイパスし、CloudWatch に必ず載る形を確認する
作業ログ
2026-07-15
タスク doc 起票、Phase 1 の manifest 編集まで実施
Phase 1 実施完了。
kubectl apply後、basic-tiflash-0が Up (store id 7097)、pd-ctl storeで engine=tiflash 1 store 確認Phase 4 dry-run中のクラスタ負荷を確認。node3のPLaMoが約14.1/16 CPUを使用する一方、node1/node2は各約0.2 CPU、available memoryは各ノード約21〜24GiBあり、node2/node3の2 Pod分散を採用
kubectl port-forward svc/plamo-embeddingは1 Podへ直接転送されるため、Tailnet LoadBalancer Service (plamo-embedding.<tailnet>) 経由へ変更TiFlashが
3/4 CrashLoopBackOffになっていることを検出。全133記事のembeddingがNULLの状態でHNSWを先行作成した結果、DMFileVectorIndexWriter→FramedChecksumReadBuffer::doSeekでframe size 0の除算が発生 (exit 136)TiDB側でindexをdropした後も、TiFlashはPVC上の古いlocal-index taskを起動直後に再開してCrashLoopを継続。replicaを0へ切り替え、必要時は分析用PVCを再作成する復旧手順を3-4へ記録
HNSW作成をPhase 4のbackfill + TiFlash replica再同期 +
ALTER TABLE articles COMPACT後へ移動1記事1vectorの精度確認では
Rust Axum APIに対し本文全体がdistance 0.4674、タイトルのみが0.3815となり、長文による話題の希釈を確認PLaMOの学習時context長1024 tokensに合わせ、同じtokenizerでMarkdownを見出し単位 + 128 tokens overlapに分割する方式へ変更
article_embedding_chunksとsource hashによる差分backfillを実装。全vector生成後に記事単位transactionで差し替える
2026-07-16
Phase 7 実装。
EmbeddingClientにchunk_documentを追加し、compute_source_hashを tidb-embedder と同じ JSON レイアウトで書いて後段バッチと hash が揃うようにしたArticlesRepository::replace_article_chunksを追加。article_embedding_chunksの記事単位 transaction 差し替えを webhook でも使えるようにしたhandler/webhooks.rsで title / description / content の変更を検出したら chunk 再生成を走らせるようにした。effective description の判定はupsert_articleの書き込み挙動と同じ (frontmatter が None なら既存値、無ければ title) に揃えた失敗時は
warn!を残して既存 chunk を保持し、記事 upsert 自体は成功扱い。PLAMO_EMBED_ENDPOINT未設定時は skip ログのみ (dev のみ有効化)動作確認は次回、
PLAMO_EMBED_ENDPOINT=http://plamo-embedding.${TAILNET}を渡した状態で webhook を再送して行うdev デプロイ後 (2c9371b, 02:44:16Z 反映) に webhook 再送。
blog_dev.article_embedding_chunksの0c646f1b-...に 3 chunks が created_at=02:45:01.427842 で差し替え済み ✅ただし CloudWatch Logs で
process_push_eventループ内側の info!/warn! (Article upserted、Article chunks replaced 等) が全部消失。ループ外側の info! と DB 書き込みは動作しているので機能面は OK。詳細は 7-5 followup に記録