
NVIDIA Cosmos 3 と Milvus + Hermes で自然文動画検索を DGX Spark で試してみた
はじめに
こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。
NVIDIA VSS (Video Search and Summarization) 3.2 GA が 2026 年 6 月にリリースされて、Search Profile という alpha 機能が入りました。動画をタグ付けして自然文で検索できる、という個人的に盛り上がる機能ですね。
DGX Spark でこれを動かせば、素の Cosmos 3 Nano-Reasoner を VLM としてそのまま流し込んで、「ラベンダー畑の中の一本道」みたいな自然文で Pexels のデモ動画を検索するデモがローカル環境で組めそう。そう思って着手した週末プロジェクトの紹介です。
先に結論を書いておくと、VSS 3.2 GA の Search Profile は現時点で公式的に DGX Spark を対応外になっていました。dev-profile.sh の hardware profile 表に「DGX-SPARK は base / alerts のみ」と明記されていて、原因は DeepStream perception-2d の SBSA arm64 build がまだ整備されていないことにあります。ここに至るまでの 5 日間の壁物語を延々書いてもいいのですが、供養のため :::details に畳んでおくにとどめました。
代わりにこの記事の主軸は、DGX Spark 1 台完結で Search Profile 相当の自然文動画検索デモを組むための別解の方に寄せています。1 台の DGX Spark 上に Cosmos 3 Nano-Reasoner の vLLM (タグ生成 + 自然文パース) と Milvus standalone (タグの scalar filter 検索) を同居させて、手元の Mac などから Hermes Agent の 4 profile + MCP vss-search-demo server で対話的に検索する、という 3 点セットです。ラベンダー畑を主軸に集めた 42 本の Pexels 動画に対して自然文クエリを 10 本流して、ベースラインの Mean Recall@5 = 0.540 まで通しています。「検索上位 5 件に入っていてほしい正解動画を、平均して半分強は拾えている」状態で、「紫色の花畑」のような相性のいいクエリなら正解を全部上位に出せるところまで来ました。
「VSS 3.2 GA が DGX Spark で動くのか気になっていた人」「素の Cosmos 3 で自然文検索をやってみたい人」「Hermes Agent + MCP で対話 UI を組みたい人」あたりに刺さるといいなと思っています。
VSS 3.2 GA Search Profile とは
VSS (Video Search and Summarization) 3.2 GA は 2026 年 6 月 16 日にリリースされた、NVIDIA 公式の動画理解 Blueprint です。base profile (動画キャプション生成 + 要約) と alerts profile (異常検知) は 3.1 EA から続いていて、3.2 GA で Search Profile が加わりました。base profile を DGX Spark で動かした話は前回の記事で扱っています。
Search Profile はざっくり次の 4 コンポーネントを束ねます。
- RT-VLM が VLM を動画フレームに流し、構造化タグを JSON で吐かせる。今回はここに素の Cosmos 3 Nano-Reasoner を差し込みたい
- RT-Embed は Cosmos-Embed1-448p (Cosmos ファミリーの video CLIP、768 次元の embedding を返す) を担当
- Milvus が RT-Embed の 768d ベクトルを ANN で索引化
- Elasticsearch + fusion_search_rerank が RT-VLM のタグを scalar 属性フィルターとして受け、Milvus の vector 検索結果と
rrf_attr(Reciprocal Rank Fusion + 属性重み) で融合してリランクする
「動画に対して VLM がタグを付ける」「同じ動画に対して CLIP が embedding を作る」「その両方を fusion して自然文で検索する」という、動画検索版の hybrid search の教科書的構成が入っている感じですね。
DGX Spark では Search Profile が公式非対応だった
VSS 3.2 GA の Search Profile は、公式の dev-profile.sh の時点で DGX Spark を対応外にしていました。。。
dev-profile.sh --help を読むと、hardware profile と VSS profile の組み合わせがこう定義されています。
| Hardware Profile | base | alerts | search | lvs |
|---|---|---|---|---|
| H100 / L40S / RTXPRO4500BW / RTXPRO6000BW (x86) | ✅ | ✅ | ✅ | ✅ |
| DGX-SPARK (GB10, SBSA arm64) | ✅ | ✅ | ❌ | ❌ |
| IGX-THOR / AGX-THOR (Jetson Thor) | ✅ | ✅ | ❌ | ❌ |
Search Profile は x86 + data-center / workstation GPU 前提で、ARM 系ハード (DGX Spark も Jetson Thor も) は base / alerts までしか公式が保証していない設計です。
ARM 系がまとめて対応外になっている理由を公式は明言していないのですが、個人的にはこんな見立てかなと思っています。
- DeepStream perception-2d の SBSA arm64 build が現時点で未整備。NVIDIA は DeepStream を x86_64 と Tegra (Jetson) の 2 系統でしか build していなくて、SBSA arm64 用の中間パスがまだ揃っていません。実際、後述の実機検証で DeepStream pipeline が Tegra Resource Manager 依存で空回りする症状が確認できました
- Search Profile が同時に要求する GPU リソースが edge クラスに厳しい。RT-CV + RT-Embed + RT-VLM + LLM + Milvus + Elasticsearch + Kafka を同時に載せる想定で、data-center GPU 前提の設計になっています
ちなみに -H OTHER に切り替えると起動ロジック自体はすり抜けられるのですがそれはそれで以下のような深い沼にハマります。。。
5 日間の壁物語
壁 1: -H DGX-SPARK は search profile では使えない
まず最初、素直に DGX Spark プロファイルを指定して起動を試みると、こう返ってきます。
[ERROR] Hardware profile 'DGX-SPARK' is only valid for profile base or alerts, not 'search'
dev-profile.sh --help を読み直すと、確かに DGX-SPARK, IGX-THOR, AGX-THOR only valid when profile is base or alerts と書いてありました。search profile では -H DGX-SPARK は最初から想定外の組み合わせで、-H OTHER に切り替える必要があります。
ちょっと拍子抜けするポイントですね。ここで気になるのが、-H OTHER にした瞬間に「じゃあ device id はどうなるの」という次の話です。
壁 2: NGC_CLI_API_KEY の env が必須
-H OTHER -p search で再挑戦すると、今度は違うエラーが返ってきます。
[ERROR] NGC_CLI_API_KEY is required for desired-state 'up'
~/.ngc/config に apikey を設定してあっても、dev-profile.sh はこの env variable を直接読みに行きます。host 側で export NGC_CLI_API_KEY=... してから sudo -E で起動する形になりますね。
自分の場合、過去に一度 sudo で叩いた影響で ~/.ngc/ ディレクトリが root 所有になっていて、chown -R morishige:morishige ~/.ngc/ が事前に必要でした。ここは環境依存ですね。
壁 3: DGX Spark の単 GPU で LLM/VLM の device を確保できない
env を通して再挑戦すると、次はここで詰まります。
** ERROR: nvidia-container-cli: device error: 1: unknown device
もしくは Device ID 0 is reserved and cannot be assigned to LLM or VLM for this profile。VSS の search profile は multi-GPU 前提で設計されていて、.env に RESERVED_DEVICE_IDS='0'、RT_CV_DEVICE_ID='0'、RT_EMBED_DEVICE_ID='1'、LLM_DEVICE_ID='1'、VLM_DEVICE_ID='2' と 3 GPU 分の割り当てが決め打ちで入っていました。
DGX Spark には GPU が 1 個 (id=0) しかないので、この時点で local LLM/VLM を同居させる経路は物理的に破綻しています。ヘルプにも --llm-device-id, --vlm-device-id: DGX-SPARK, IGX-THOR, AGX-THOR: not accepted と明記されているので、公式が「DGX Spark では device id を渡すな」と言っています。
そこで 2 ノード split に切り替えました。DGX Spark 2 台のうち、片方を VSS ホスト (以下 メイン機)、もう片方を LLM/VLM 用の vLLM ホスト (以下 推論機) と役割分担して、--use-remote-llm + --use-remote-vlm の両方を有効化。推論機では素の Cosmos 3 Nano-Reasoner (port 8001) と Nemotron 3 Nano 30B A3B NVFP4 (port 9000) を起動しておいて、内部 QSFP 直結ネットワーク (192.168.200.0/24、RTT 1ms) 経由で VSS から叩かせる構成です。
これで LLM/VLM の device 問題は解消です。……のはずでした。
壁 4: .env の RT_EMBED_DEVICE_ID='1' が hardcoded
remote LLM/VLM の env を渡してもう一度叩いてみると、また同じエラー。
** ERROR: nvidia-container-cli: device error: 1: unknown device
「え、remote 化したじゃん」となりますね。犯人はまた .env でした。LLM/VLM を remote に逃がしても、RT-Embed (Cosmos-Embed1-448p を動かすコンテナ) が独立に device 1 を要求していたんです。
対応は .env を 1 行書き換えて RT_EMBED_DEVICE_ID='0' に変えるだけ。RT-CV と RT-Embed が同じ device 0 を共有する形になりますが、DGX Spark は UMA で物理的に同じメモリプールなので、GPU 帯域の競合はあれど動作は成立します。バックアップに .env.bak-original を残しておくと、後で戻せて安心ですね。
壁 5: RT-CV 初期化スクリプトが x86_64 専用 NGC CLI を取りに行く
これで動くはず、と思って再度実行。すると今度は init container の一つで exit 126 が出てきました。
##### NGC CLI not found, installing... #####
/opt/scripts/download-vision-encoder.sh: line 66: /tmp/ngc-cli/ngc: cannot execute binary file: Exec format error
Exec format error はアーキテクチャミスマッチのお決まりのエラーです。よく見ると、download-vision-encoder.sh の line 62 で wget https://ngc.nvidia.com/downloads/ngccli_linux.zip と、x86_64 専用の zip を DL していました。DGX Spark は aarch64 なので、そのまま実行しようとして落ちます。
このスクリプトは container の image に埋め込まれているのではなく、compose.yml で host から bind mount されているので、host 側の 1 ファイルを書き換えるだけで済みます。uname -m で分岐して aarch64 なら ngccli_arm64.zip を取りに行くように改造しました。arm64 版も NGC から HTTP 200 で提供されていることは事前に確認済みです。
case "$(uname -m)" in
aarch64|arm64) NGC_ZIP=ngccli_arm64.zip ;;
x86_64) NGC_ZIP=ngccli_linux.zip ;;
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
wget -q "https://ngc.nvidia.com/downloads/${NGC_ZIP}" -O "${NGC_ZIP}"
unzip -q -o "${NGC_ZIP}" && chmod +x ngc-cli/ngc
これで RT-CV init container が Cosmos-Embed1-448p の重みを NGC から DL できるようになって、全 20+ container が Up 状態に到達しました。Search Profile が立ち上がった瞬間ですね。
……と思ったら、DeepStream perception-2d が起動しない。ここから壁 6 に入ります。
壁 6: 素の 3.2.0 タグは Jetson Tegra 用、DGX Spark には 3.2.0-sbsa を使う
DeepStream perception-2d (VSS の RT-CV) は動画を読み出して Cosmos-Embed1-448p にフレームを流し込む、Search Profile の embedding pipeline の心臓部です。ここが動かないと、いくら VST に動画を投入しても embedding が生成されず、Elasticsearch にもタグが入りません。
素直に dev-profile.sh up すると、こんなログが出て起動失敗しました。
** ERROR: <create_nvmultiurisrcbin_bin:1682>: Failed to create element 'src_nvmultiurisrcbin'
** ERROR: <create_pipeline:2469>: create_pipeline failed
** ERROR: <main:2188>: Failed to create pipeline
GStreamer plugin の依存を ldd で洗い出すと、libnvrm_* (Tegra Resource Manager) や libnvos.so、libnvvic.so (Tegra Video Image Compositor) といった、Jetson Tegra プラットフォーム専用のハードウェア抽象が not found で解決できていませんでした。DGX Spark の GB10 は Tegra ではなく SBSA (Server Base System Architecture) の arm64 なので、この Tegra ライブラリ群は物理的に存在しません。
「これは本質的に動かないやつだな」と一度は諦めかけました。ただ、諦める前に GitHub の VSS リポジトリで似た症状の Issue を眺めていたら、Issue #111 のスレッドに一般原則としての答えがあったんです。この Issue 自体は Long Video Summarization (LVS) profile の話で Search Profile とは別ですが、libnvrm_* 依存で decoder が落ちる症状は今回の壁 6 とほぼ同じでした。
NVIDIA のメンテナ pshekdar-nv の 2026-06-29 のコメントに、こんな一節が書かれています (ちょうど検証していた翌日の投稿でした)。
For DGX Spark (GB10 / ARM64 SBSA), use the -sbsa image variants rather than the x86 tag.
続く LVS の具体的な image 名や LVS_TAG の設定例は割愛しますが、要するに DGX Spark GB10 (SBSA arm64) では、素の :3.2.0 タグではなく :3.2.0-sbsa タグを引きなさい、x86 と Jetson Tegra は素の :3.2.0 で OK という原則です。
この一般原則を Search Profile 側のイメージにも当てはめて、実機の docker images を眺めてみたら、確かに次の 3 つの sbsa タグが NGC に存在していました。
nvcr.io/nvidia/vss-core/vss-rt-cv:3.2.0-sbsanvcr.io/nvidia/vss-core/vss-rt-embed:3.2.0-sbsanvcr.io/nvidia/vss-core/vss-rt-vlm:3.2.0-sbsa
つまり 素の :3.2.0 タグは Jetson Tegra 用、DGX Spark GB10 には別途 :3.2.0-sbsa タグが用意されている。自分は素の 3.2.0 タグを引いていて、それが「arm64 だけど Tegra ビルド」だったために libnvrm_* を要求していたわけですね。
さらに面白いのが、実は .env の中に # PERCEPTION_TAG="3.2.0-sbsa" というコメントアウトされた 1 行が最初から入っていたことでした。メンテナ側は sbsa タグの存在を知っていて、コメントとして残していたけれど、default は素の 3.2.0 (Jetson 用) のまま、-H DGX-SPARK を渡した時に自動選択する仕組みは実装されていない、というのが実情のようです。
対応は .env を 3 行書き換えるだけです。
PERCEPTION_TAG="3.2.0-sbsa"
RTVI_EMBED_TAG=3.2.0-sbsa
RTVI_VLM_IMAGE_TAG=3.2.0-sbsa
3 つの image (rt-vlm / rt-cv / rt-embed、各 29-31 GB) を NGC から pull し直して、dev-profile.sh up を再実行。すると DeepStream perception-2d の起動ログはこう変わりました。
nvdsvisionencoder [TRT]: Engine built and saved: /opt/storage/siglip_v2_v1.1.onnx_batch16.plan
Device Number: 0
Device name: NVIDIA GB10
Device Supports Optical Flow Functionality
[NvMultiObjectTracker] Initialized
Active sources : 0
Failed to create element は消え、Vision Encoder が TensorRT engine を build して SigLIP を組み立て、NVIDIA GB10 を正しく認識、Optical Flow (nvof) も対応確認、Multi-Object Tracker まで起動。この時点では 壁 6 は sbsa タグで解消した と思っていました。
ただ、これは半分正解で半分不正解でした。container の起動 (プロセスが上がって init 完了ログが出るところ) は確かに解消していますが、実際の GStreamer pipeline の内部処理は nvvideoconvert / nvof / VIC 経由で Tegra Resource Manager を呼ぶ設計になっているので、SBSA arm64 環境では実行時に静かに空回りする、というのが後の壁 8 で判明します。この点については壁 8 のセクションで詳しく触れます。
一つコラム的な学びとしては、素直に諦める前に upstream の Issue を一度覗いたほうがいい、というありふれた話です。壁 3 に着手する前に Issue #111 を見つけていれば、2 ノード split の遠回りは要らなかったですね。
壁 7: docker network 分離で rt-embed が host のサービスに届かない
さて、embedding pipeline が起動したので、検証用に集めておいた 35 本の Pexels 動画 (データセットの中身は後の章で紹介します) を VST に PUT で投入して、agent の /api/v1/videos/{sensor_id}/complete を叩いてみたら、こんなエラーが返ってきました。
{
"detail": "Embedding generation failed: Embedding generation failed with status 500:
{\"code\":\"InternalServerError\",\"message\":\"An internal server error occurred\"}"
}
rt-embed のログを掘ると原因はこれ。
aiohttp.client_exceptions.ConnectionTimeoutError:
Connection timeout to host http://192.168.64.198:30888/vst/storage/temp_files/pexels_XXX.mp4
rt-embed が VST から動画をダウンロードしようとして、host の 192.168.64.198:30888 (VST の nginx port) に到達できていない。同じホスト上の container なのに timeout している時点で、docker network の routing 問題ですね。
原因はシンプルでした。rt-embed は mdx_default bridge network で動いていて、VST は network_mode: "host" で host に直 bind している。両者は同じホストにいるものの、bridge network の container から host のプロセスに直接届くには、ufw で当該 port が明示的に allow されていないと INPUT DROP policy で落とされます。
対応は 2 つの port を ufw で開けるだけ。
sudo ufw allow 30888/tcp comment 'VSS VST'
sudo ufw allow 9200/tcp comment 'VSS Elasticsearch'
9200 の方は、embedding を投入する Elasticsearch も同じ経路で block されていたので同時に開けます。
ufw allow した瞬間、rt-embed の HTTP テストが 000 から 200 に変わって、/api/v1/videos/{sensor_id}/complete が embedding 生成を成功で返すようになりました。
{
"message": "Video pexels_XXX.mp4 successfully uploaded to VST and embeddings generated",
"sensor_id": "003203c3-d046-4f64-b2e3-0cc58f97e87e",
"filename": "pexels_XXX.mp4",
"chunks_processed": 2
}
35 本を一気に流したら 33/35 が embedding 生成成功、Elasticsearch の mdx-raw-2025-01-01 index には 5,490 個の embedding document (52.7 MB) が実際に投入されました。1 動画あたり 2 chunk 相当が Cosmos-Embed1-448p で 768d ベクトル化されて格納された形です。推論機の Nemotron 3 Nano には agent が agent_mode: true で query 解析を投げた POST が 1 件記録されて、remote LLM への実際の疎通も証拠が取れました。
ここで自分の理解を先出しで書いておくと、この時点でのアップロード経路は旧 shim の PUT /api/v1/videos-for-search/{filename} を叩いていました (壁 8 で気付きます)。旧 shim は rt-embed に直接動画を fetch させて生 embedding を mdx-raw に投入するだけの縮退経路で、agent の post-processing pipeline (DeepStream perception-2d 経由) は経由していません。そのおかげで壁 6 の DeepStream 空回りが顕在化せずに 5,490 docs まで届いた、というのが後で振り返って分かる整理です。
壁 8: 旧 /videos-for-search shim と新 three-step flow の schema 差
Embedding pipeline は動いた。ここで自然文検索 POST /api/v1/search を叩いてみると、こう返ってきました。
{
"code": "workflow_error",
"message": "404: Search index 'mdx-embed-filtered-2025-01-01' does not exist.
Please ensure videos have been ingested before searching.",
"details": "ExecutionFailed"
}
Elasticsearch には mdx-raw-2025-01-01 (5,490 docs) と mdx-behavior-2025-01-01 (152 docs) はあるのに、agent が検索したい mdx-embed-filtered-2025-01-01 だけが存在しない、という状態。
もう一度 upstream の Issue を眺めてみたら Issue #384 がヒントを持っていました。ここには VSS 3.2 GA で 動画 upload の canonical API が three-step flow に置き換わったことが書いてありました。
POST /api/v1/videosに filename を送ると、agent が nvstreamer の chunked upload URL を返す- UI がその URL に chunked POST → VST が sensorId を返す
POST /api/v1/videos/{sensor_id}/completeを叩くと、agent が timeline 登録、RTVI-CV register、embedding generation、filtered index への投入までの post-processing を一括で走らせる
一方で、旧 PUT /api/v1/videos-for-search/{filename} は deprecated shim としてまだ残っていて (Issue #384 で「削除される予定」とされている)、壁 7 まで自分が叩いていたのはこの deprecated 経路でした。旧経路は rt-embed が動画を fetch して生 embedding を mdx-raw に投入するだけで、agent の post-processing (RTVI-CV register + filtered index 投入) は経由しないので、mdx-raw には 5,490 docs 入るけれど mdx-embed-filtered は空、という壁 7 で見えた状態の説明が付きました。
VST UI JS bundle から chunked upload の wire format を復元する
three-step flow に切り替えようとしたら、step 2 の chunked upload のリクエスト仕様がドキュメント化されていないことに気付きました。単純な POST --data-binary @file.mp4 を投げても VMSInternalError: Timestamp is 0 OR sensorId OR mediaFilePath is not present で弾かれます。仕様書がないなら、実際に動いている VST UI の JS bundle を読み解く、というのが早いですね。
http://<host>:7777/vst/ を curl すると VST UI の HTML が返って、そこから assets/js/index-*.js を辿れます。webpack でまとめられた minified bundle ですが、chunkedUpload という symbol 名を軸に検索すれば、実装本体を数分で特定できました。読み解いた結果、three-step の完全 wire format はこうなっていました。
# Step 1: POST /api/v1/videos → 実 upload URL を貰う
r = requests.post(f"{base}/api/v1/videos", json={"filename": FNAME})
upload_url = r.json()["url"]
# 実測: "http://192.168.64.198:7777/vst/api/v1/storage/file"
# Step 2: multipart/form-data で chunk 単位に POST
# 全 chunk で同じ UUID (identifier) を共有、最後の chunk のレスポンスで sensorId が返る
ident = str(uuid.uuid4())
for chunk_num in range(1, total + 1):
chunk = f.read(chunk_size)
headers = {
"nvstreamer-chunk-number": str(chunk_num),
"nvstreamer-total-chunks": str(total),
"nvstreamer-is-last-chunk": "true" if chunk_num == total else "false",
"nvstreamer-identifier": ident,
"nvstreamer-file-name": FNAME,
}
files = {
"mediaFile": (FNAME, chunk, "application/octet-stream"),
"filename": (None, FNAME),
"metadata": (None, '{"timestamp":"2025-01-01T00:00:00"}'),
}
r = requests.post(upload_url, headers=headers, files=files)
last_response = r.json() # {"sensorId": ..., "chunkIdentifier": ..., "filePath": ..., ...}
# Step 3: sensor_id 付きで complete を叩くと agent の post-processing が走る
sensor_id = last_response["sensorId"]
r = requests.post(
f"{base}/api/v1/videos/{sensor_id}/complete",
json={**last_response, "filename": FNAME},
)
# {"message": "... embeddings generated", "sensor_id": ..., "chunks_processed": 8}
キーになるのは 2 点でした。1 つは step 2 の宛先 URL は step 1 で返る url で、事前に固定 nvstreamer path を決め打つのではないこと。もう 1 つは sensorId は step 2 のレスポンスから受け取る値であって、step 2 の request では送らない、という順序です。以前ハマった sensorId is not present のエラーメッセージは「あなたが送っていない」ではなく「chunk が欠けていて sensorId が確定できない」の意味だった、というオチですね。
完全再現はできた、しかし ES に何も入らない
三段階の flow を Python 100 行に落とし込んで、実機で 25 MB のラベンダー動画を投げてみます。7 チャンクに分割されて、step 1 は 200、step 2 は 7 chunks 全部 200、step 3 は 200 と返ってきました。
[step1] POST /api/v1/videos -> 200
[step1] response: {"url": "http://192.168.64.198:7777/vst/api/v1/storage/file"}
[step2] chunk 1/7 -> 200 (4194304 bytes, 0.01s)
...
[step2] chunk 7/7 -> 200 (672218 bytes, 0.13s)
[step2] final response: {"bytes": 25838042, "sensorId": "16c5571a-...", ...}
[step3] POST /api/v1/videos/16c5571a-.../complete -> 200
[step3] response: {
"message": "Video spike-lavender.mp4 successfully uploaded to VST and embeddings generated",
"sensor_id": "16c5571a-427b-4f47-b6e4-306005e7cb38",
"filename": "spike-lavender.mp4",
"chunks_processed": 8
}
chunks_processed: 8 に "embeddings generated" の応答。文字面だけ見ると成功に見えます。ただ、Elasticsearch を確認するとこうです。
$ curl -s http://localhost:9200/_cat/indices?v | grep mdx
mdx-raw-2025-01-01 5490 docs 52.7mb (変化なし)
mdx-behavior-2025-01-01 152 docs 3.5mb
spike の sensor_id で mdx-raw を検索すると 0 hits。3 段全部 200 で通ったにもかかわらず、生 embedding すら 1 件も入っていません。search API も相変わらず mdx-embed-filtered-2025-01-01 does not exist で 404 のままでした。
つまり agent の "embeddings generated" レスポンスは、実際の embedding pipeline の結果ではなく nominal な成功応答だった、というのが今回の壁 8 で見えた本当の姿です。step 3 で agent が呼ぶ post-processing の実体は、壁 6 で深掘りした DeepStream perception-2d の pipeline そのもので、これが SBSA arm64 環境で本質的に動かない (nvvideoconvert / nvof / VIC が Tegra Resource Manager 依存) 状態が、step 3 の応答レイヤーでは可視化されずに黙って空回りする、という構造でした。
言い換えると、壁 6 と壁 8 は new three-step flow を経由するときの 2 つの症状で、Tegra 依存 DeepStream という同じ根源が step 3 のレスポンスと ES 投入結果の乖離として現れていた、というのが実機で判明した結論です。SBSA タグに切り替えて container が起動できるところまでは行きましたが、pipeline の内部処理は動いていない。表と裏でスコアが違っていた、というのが正直な状態ですね (壁 7 で 5,490 docs 入ったのは旧 shim の縮退経路が DeepStream を経由しない別経路だったから、というのは前述の通り)。
ES に alias で mdx-raw-2025-01-01 を mdx-embed-filtered-2025-01-01 として見せる workaround も試しました。すると search API のエラーは 404 から 400 に進化して、こう返ってきます。
400: BadRequestError, search_phase_execution_exception,
[nested] failed to find nested object under path [llm.visionEmbeddings]
mdx-raw と mdx-embed-filtered は schema が違うんですね。前者は rt-embed の raw output (objects[*].embedding.vector に格納)、後者は agent が期待する filtered 版 (llm.visionEmbeddings の nested path が必要)。schema 変換は step 3 の post-processing pipeline (= DeepStream) が担うので、pipeline が空回りしている限り filtered index には何も入らない、という一貫した挙動でした。
ここまでのスコアカード
長かったですが、整理しておきます。
| # | 壁 | 状態 |
|---|---|---|
| 1 | -H DGX-SPARK -p search 不可 |
✅ -H OTHER |
| 2 | NGC_CLI_API_KEY env 必須 |
✅ host export + sudo -E |
| 3 | 単 GPU で LLM/VLM device 不足 | ✅ 2 ノード split + remote 化 (※実は必須ではなかった、後述) |
| 4 | RT_EMBED_DEVICE_ID='1' hardcoded |
✅ .env パッチで =0 |
| 5 | download-vision-encoder.sh が x86_64 専用 |
✅ arch 検出パッチ |
| 6 | 素の 3.2.0 タグは Jetson Tegra 用 |
🟡 3.2.0-sbsa タグで container は起動、ただし DeepStream pipeline の内部処理は Tegra Resource Manager 依存で空回り |
| 7 | rt-embed → host services が docker network 分離で不到達 | ✅ ufw allow 30888/9200 |
| 8 | 旧 shim vs 新 three-step flow の wire format 差 | 🟡 VST UI JS から wire format 復元 + Python で 3 段全部 200 実現、ただし ES への投入はゼロ (= 壁 6 の DeepStream 空回りの下流症状) |
「頂上の一歩手前まで登った」のではなくて、山頂手前で登山道自体が SBSA arm64 用に整備されていなかった、というのが今回の到達点でした。壁 6 と壁 8 は new three-step flow で agent が DeepStream perception-2d を経由するときの 2 つの症状で、SBSA arm64 build 不在という同じ根源が container 起動失敗 (壁 6 前半) と nominal 成功応答なのに空回り (壁 8) として現れていた形ですね。壁 7 で 5,490 docs 入ったのは旧 shim が DeepStream を経由しない別経路だった、という補足つきです。
Cosmos 3 + Milvus + Hermes に方針転換する
というわけで、VSS 3.2 GA Search Profile を DGX Spark 単体で通すのは、SBSA 用 DeepStream の upstream 対応を待つか、H100 等の x86 環境に持っていくかの二択でした。ただ「じゃあ DGX Spark で自然文動画検索デモは組めないのか」というと、そんなことはなくて、Cosmos 3 Nano-Reasoner のタグ生成 + 自前 Milvus + Hermes Agent MCP の 3 点セットで、Search Profile が提供しようとしている機能とほぼ同等のものを DGX Spark 1 台で完結させられます。
具体的には、こんな置き換えです。
| VSS Search Profile が担う機能 | 別解での置き換え |
|---|---|
| RT-VLM (VLM でタグ生成) | 素の Cosmos 3 Nano-Reasoner をローカル vLLM で直接叩く (この後の章で実装) |
| RT-CV + RT-Embed (embedding 生成) | Cosmos-Embed1-448p の自前 inference (今回は dummy vector で骨組み、続編?) |
| Milvus + Elasticsearch | Milvus standalone を単体で立てて、5 軸タグは scalar filter として使う |
| fusion_search_rerank | 今回は scalar filter + top-K のみ (BM25 相当 sparse + vector の融合は続編?) |
| Agent UI + agent_mode LLM | Hermes Agent 4 profile + MCP vss-search-demo server で対話 UI と MCP 制御 |
こう並べると、VSS の Search Profile が提供する機能はほぼ全部、DGX Spark の Cosmos 3 と Milvus と Hermes だけで組み直せることが見えてきます。むしろ「1 台完結」「$0」「素の Cosmos 3 Nano-Reasoner を検証しながら使える」というエッジ寄りの利点も付いてきます。ここからは、デモ動画のデータセット作り、Cosmos 3 のタグ生成、検索デモの組み立て、の順で実装に入ります。
デモ動画 42 本を Pexels で揃える
デモを走らせるためには、いくつかの前提を意図的にコントロールしたデータセットが必要になります。今回は「ラベンダー畑の中の一本道」を主軸クエリに据えたので、まず Pexels API から次の 4 カテゴリで 35 本の動画を集めました。
| カテゴリ | 本数 | 動画の特徴 | 期待挙動 |
|---|---|---|---|
| main_lavender_path | 8 | 紫のラベンダー畑を貫く一本道の動画 | 主軸クエリで全部返ってきてほしい |
| similar_other_vegetation | 8 | ひまわり畑・麦畑・チューリップ畑等の「別植物 × 花畑」 | 植物軸で分離、色軸で近縁と識別されてほしい |
| similar_other_environment | 7 | 森の道・田舎道・都会の道等の「別環境 × 一本道」 | 構図軸で近縁だが植物軸で違うと判別されてほしい |
| distractor | 12 | 夜のカフェ・料理・スポーツ等の全く無関係な動画 | 主軸クエリでは混ざってほしくない |
それぞれのカテゴリの代表フレームを 1 枚ずつ並べておきます (すべて Pexels の動画から中央付近のフレームを 1 枚抜き出したものです)。

main_lavender_path 代表 (pexels 15958460): ドローン視点で広がるラベンダー畑、主軸クエリ「ラベンダー畑の中の一本道」の中心

similar_other_vegetation 代表 (pexels 13977274): 向日葵畑を水平にパンした構図、色軸 (黄 vs 紫) と植物軸 (sunflower vs lavender) の対比

similar_other_environment 代表 (pexels 32509204): 夕暮れの空き道、構図軸 (single_path) は近いが植物軸で違うタイプ

distractor 代表 (pexels 32284504): 夜の歌舞伎町、主軸クエリと無関係な precision チェック用
main_lavender_path の 8 本が「主 5 本 + 拡張 3 本」の構成になっているのは、後の Recall@5 評価で「主軸を全部取れるか」を明快に測るためですね。similar_other_* を挟むことで、prompt enum の粒度で近縁カテゴリを分離できるかも同時に測れます。distractor は precision (誤判定) チェック用です。
これに加えて、後の enum 拡張検証で使う tricky 5 本 (紫陽花・藤・ライラック・菖蒲・クロッカス) + close-up 2 本 (ライラック接写) の合計 7 本を追加してあります。全体としては 35 + 7 = 42 動画、これが Milvus の cosmos3_videos コレクションの中身です。
Pexels API の使い方自体は素朴で、search_query を投げて duration_sec / width / height / file_link を回収して dataset-25.json として保存する形にしています。ただし Pexels の動画は横長 HD が中心でトリミング済みなので、8 フレーム抽出したときの構図が均質になる、という副次的なメリットがありますね。
Cosmos 3 Nano-Reasoner で 35 本にタグを生成する
35 本の動画に対して、素の Cosmos 3 Nano-Reasoner (Qwen3-VL-8B ベースの 8.77B、BF16 で 18GB) をそのまま使って、5 軸のタグを構造化 JSON で吐かせます。VSS 3.2 GA では VLM が動画キャプションを生成する仕組みですが、キャプションのままだと Milvus / Elasticsearch でメタ検索できないので、それぞれ enum 化した JSON schema で吐かせるのがポイントですね。
主軸のラベンダー畑動画に対して、Cosmos 3 Nano-Reasoner が何を「見て」いるかのイメージとして、タグ生成対象の動画の代表フレームを 1 枚だけ載せておきます。

タグ生成対象の動画から (pexels 8858844): ドローン視点でラベンダー畑を上空から捉えた typical な 1 枚。この動画からは vegetation=lavender / color_dominant=purple / composition=aerial の 5 軸タグと、「風がラベンダー畑を優しく揺らす様子が、均一な間隔で捉えられた連続したフレームです」の temporal_caption が生成されました
プロンプトはこんな感じです。
あなたは映像のシーンタグ生成専門エージェントです。動画から等間隔で抽出した
8 枚の連続フレームを見て、検索インデックス用の構造化メタデータを次のスキーマに
沿って JSON のみで返してください。
Schema:
{
"subjects": "映っている主要な被写体 (日本語、50 字以内)",
"actions": "撮影される動作・カメラワーク (日本語、50 字以内)",
"temporal_caption": "動画全体の自然文記述 (日本語、150 字以内)",
"vegetation": "lavender / lilac / hydrangea / wisteria / iris / sunflower / wheat / tea / other / none のいずれか",
"color_dominant": "purple / yellow / green / brown / gray / blue / other のいずれか",
"composition": "single_path / aerial / close_up / panorama / interior / other のいずれか",
"time_of_day": "morning / noon / sunset / night のいずれか",
"season": "spring / summer / autumn / winter / unknown のいずれか",
"weather": "clear / cloudy / rainy / snowy / foggy のいずれか",
"key_objects": ["主要オブジェクト 3-5 個 (日本語可)"]
}
なお、この記事で「5 軸」と呼ぶのは、このスキーマのうち検索フィルターに使う enum の 5 つ、vegetation / color_dominant / composition / time_of_day / season のことです。
vLLM 経由で 35 本を回した実機結果がこちらです。
| 指標 | 実測 |
|---|---|
| 総動画数 | 35 |
| JSON valid 率 | 100.0% |
| 5 軸タグ充足率 | 100.0% |
| レイテンシ中央値 | 20.79 秒 |
| 出力 token 中央値 | 177 |
35/35 で JSON が壊れず、5 軸すべてが埋まった状態で返ってきました。個人的にはこの数字がかなり嬉しいポイントで、FT なしで prompt 設計だけでここまで行けるなら、記事のスコープ的にも「素モデル + prompt 設計」で後半の検索デモが成立するな、と方針が固まった瞬間ですね。
カテゴリごとの期待タグ一致率もあわせて確認しておきます。
| カテゴリ | 動画数 | 期待タグ一致率 |
|---|---|---|
| main_lavender_path (主) | 8 | 100.0% |
| distractor | 12 | 91.7% |
| similar_other_environment | 7 | 85.7% |
| similar_other_vegetation | 8 | 37.5% |
主軸のラベンダー動画は 8/8 で全部主軸タグに乗り、distractor (夜のカフェ・料理風景・都市風景等) は 12 本のうち 11 本を「lavender ではない」と正しく判別できています。similar_other_vegetation の 37.5% だけが低くて、これはひまわり・麦・お茶畑・杉林みたいな「植物だけど色調・構図が主軸とかぶるもの」で、prompt enum の粒度不足が効いてきたところでした。
prompt enum 拡張で細粒度植物識別
similar_other_vegetation の 37.5% は、prompt に含まれていない紫系植物 (紫陽花・藤・ライラック・菖蒲・クロッカス) が全部「lavender」に誤判別される、というきれいなパターンでした。素モデルはあくまで prompt が指定した enum の範囲でしか答えないので、境界を精密にしたければ prompt の enum を広げるのが正攻法ですね。
そこで 5 本の tricky 動画 (紫陽花・藤・ライラック・菖蒲・クロッカス) と、そのうちのライラックについては close-up 撮影の追加 2 本を Pexels から集めて、enum 拡張前と拡張後で挙動を比較しました。tricky 5 本 + close-up 2 本の代表フレームを先に並べておきます。

(A) ライラックの close-up 1 (pexels 4235435): 白い花穂と緑の葉。元 enum では lavender に誤判別、拡張版で lilac に正解

(B) 白い花穂の close-up (pexels 4164104): 元 enum でも拡張後も 両方とも lavender と誤判別 される 1 本。素モデルは「白い房状の花穂 = ラベンダー」と主張して譲らない、という面白い境界条件

(C) 紫陽花 close-up (pexels 19323402): 元 enum では other、拡張版で hydrangea に正解

(D) 藤のトンネル (pexels 4328491): 元 enum では other、拡張版で wisteria に正解。これは close-up ではなく single_path 構図で拡張版が拾えた例

(E) アイリス close-up (pexels 4362899): 元 enum では other、拡張版で iris に正解

(F) サクラソウ close-up (pexels 11449289): 元 enum でも拡張版でも other。enum に primrose が含まれていないので、これは想定通り「other に落ちる」正しい挙動
元の enum のまま: 5 軸のうち vegetation が lavender / sunflower / wheat / tea / other / none の 6 値だった時点では、tricky 5 本は 4/5 が「lavender ではない」と判断されて other に落ちましたが、ライラックだけは「lavender と判別」で誤判定です。ライラックの close-up 2 本 (A) (B) を追加したところ、2/2 とも lavender と判別されて、close-up 撮影だと素モデルはライラック↔ラベンダーの粒度で判別できない、というのが再現しました。
enum を拡張 (lilac / hydrangea / wisteria / iris を追加): 拡張版で改めて同じ 7 本を回したら、(A) のライラックは lilac に、(C) 紫陽花は hydrangea に、(D) 藤のトンネルは wisteria に、(E) アイリスは iris に、それぞれ正しく落ちるようになりました。素モデルの判別能力自体は元々あって、prompt enum が足りていなかっただけ、というのが可視化された形ですね。
一方で (B) の白い花穂 close-up は、enum 拡張後も lavender と誤判別のままでした。Cosmos 3 の temporal_caption を読むと「白いラベンダーの花穂が風に揺れ、緑の葉が背景に揺れる様子を捉えた映像です」と、素モデル本人は「白い」と認識しつつも「ラベンダー」と主張していて、色ではなく房状の花形が主軸判断になっている、という境界条件が見えました。ここは prompt enum の粒度では拾えないタイプで、続編で扱う細粒度植物識別の FT ネタになる部分ですね。
もう一つの境界は drone shot (空撮) の紫系植物で、close-up なら 100% 判別できる紫陽花・藤・アイリスも、drone shot 画角では hydrangea/wisteria/iris が互いに混同する現象が残りました。「close-up なら prompt enum 設計だけで 100% 判別できるが、drone shot はモデル素の限界」というきれいな境界が引けた形です。
個人的な発見としては、「FT する前に prompt の enum 設計で 80% 以上まで持って行ける」という印象です。
Cosmos 3 + 自前 Milvus + Hermes で検索デモを組む
検索デモの組み立てです。ここまでで素の Cosmos 3 Nano-Reasoner が主軸カテゴリでタグ精度 100% を出せていることはわかっているので、このタグをそのまま Milvus のメタデータフィールドに入れて filter 検索するだけで、記事のゴールである自然文動画検索デモは成立します。
構成はこうなります。DGX Spark 1 台と、Hermes Agent + MCP server を動かす手元の Mac (以下、手元機) の 2 者構成で完結します。
Milvus standalone を DGX Spark で起動
Milvus は etcd + MinIO + milvus-core の 3 container 構成の standalone を Docker で起動しました。Cosmos 3 vLLM (port 8001) と Milvus (port 19530) を同じ DGX Spark に載せて、Tailscale 経由で手元機の MCP server / Hermes からアクセスします。
services:
milvus-standalone:
image: milvusdb/milvus:v2.5.18
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
environment:
ETCD_ENDPOINTS: milvus-etcd:2379
MINIO_ADDRESS: milvus-minio:9000
Collection のスキーマは video_id (PK)、category、vegetation、color_dominant、composition、time_of_day、season、weather、subjects、key_objects、temporal_caption、pexels_url、file_link、duration_sec、そして cosmos_embed (768d float vector)。ベクトルフィールドには今回 np.random の dummy を入れて、real embedding は今回のスコープ外にしています (Cosmos-Embed1-448p の自前 inference は続編で扱う予定)。
タグ生成の jsonl を 42 動画分 load
タグ生成と enum 拡張の検証で作った jsonl (35 本 + tricky 5 + close-up 2 = 42 動画分) を Milvus に投入します。
def load_phase05_data(repo_root: Path) -> list[dict]:
...
for tf in tag_files:
for line in p.read_text().splitlines():
row = json.loads(line)
tags = row.get("tags") or {}
rows.append({
"video_id": int(row["video_id"]),
"vegetation": to_str(tags.get("vegetation")),
"color_dominant": to_str(tags.get("color_dominant")),
"composition": to_str(tags.get("composition")),
...
"cosmos_embed": np.random.rand(EMBED_DIM).astype(np.float32).tolist(),
})
return rows
流してみるとこう返ってきました。
Loaded 42 videos from tag jsonl
Created 'cosmos3_videos'
Stats: {'row_count': 42}
Filter 'vegetation == lavender': 5 rows
{'video_id': 1705117, 'category': 'main_lavender_path', ...}
{'video_id': 4075834, 'category': 'unknown', 'composition': 'close_up', ...}
{'video_id': 4164104, 'category': 'unknown', 'composition': 'close_up', ...}
...
Cosmos 3 が生成したタグがそのまま Milvus のメタデータになるので、vegetation == "lavender" の filter 一発で main のラベンダー動画が返ってくるのが気持ちいいですね (表示は先頭 5 件)。enum 拡張後も lavender 判定のままだった close-up が混ざって返ってくるところまで含めて、タグ生成の結果が検索にそのまま透けて見える形です。
Hermes Agent 4 profile で対話 UI に載せる
Milvus と Cosmos 3 vLLM は揃いましたが、これを毎回 curl で叩くのは実運用として辛いですね。Hermes Agent に対話 UI を任せて、MCP server で backend を束ねる構成に載せていきます。
Hermes には profile という単位で system prompt と tool 権限を分割する仕組みがあります。動画検索デモでは 4 profile 構成にしました。この分け方は mini-office-fox の倉庫版 (AGV フリート制御) で確立した Supervisor / Operations / Safety / Reporter の設計を、動画検索ドメインに fork したものです。
| profile | 担当 | 主要 tool |
|---|---|---|
| Supervisor | 自然文受付・意図解析・他 profile への dispatch | dispatch.route / session.summary |
| Curator | 動画データ整備 (追加・タグ再生成・品質確認) | video.add / tag.regenerate / tag.review |
| Searcher | 自然文 → タグ変換 → Milvus 検索 | query.parse / search.milvus |
| Reporter | 検索結果集計・Recall@5 計算・日次レポート | report.session_summary / report.daily |
Supervisor が受けた自然文を、内容に応じて Curator / Searcher / Reporter のいずれかに dispatch する形ですね。ユーザーが「ラベンダー畑を見せて」と言えば Searcher へ、「今日の検索精度は?」と言えば Reporter へ、「Pexels の URL を投入して」と言えば Curator へ、という具合です。
MCP server の 8 tool は Agent Skills に命名を寄せる
MCP (Model Context Protocol) サーバーとして FastAPI + Pydantic + DuckDB + Milvus クライアントの構成で 8 tool を提供します。uv run vss-search-demo で起動する形にしました。
tool の命名は、VSS 3.2 GA に同梱されている NVIDIA Agent Skills のうち、今回のスコープに関係する 4 スキルに寄せています。将来 Search Profile が DGX Spark で動くようになった時に、MCP を差し替えるだけで別解 → VSS 準拠に移行できるようにしておく狙いです。
| Agent Skill | VSS Search Profile での役割 | 別解での対応 (MCP tool) |
|---|---|---|
rt-vlm |
動画フレームから 5 軸タグを VLM で生成 | tag.regenerate (DGX Spark の Cosmos 3 vLLM を叩く) |
video-search |
自然文 → 検索クエリ → 動画結果 | query.parse + search.milvus (Cosmos 3 + Milvus 経由) |
report |
検索セッションを markdown レポート化 | report.session_summary + report.daily (DuckDB 集計) |
alerts |
検出イベントに応じてアラート発報 | 本記事スコープ外 |
これを踏まえた 8 tool の全体像がこちらです。
| # | tool 名 | 引数 | 主な処理 | 呼ぶ profile |
|---|---|---|---|---|
| 1 | video.add |
pexels_url | VST に PUT + Milvus insert | Curator |
| 2 | video.list |
filter, limit | Milvus query | Curator/Searcher |
| 3 | tag.regenerate |
video_id | Cosmos 3 vLLM でタグ再生成 → Milvus upsert | Curator |
| 4 | tag.review |
video_id | 現在のタグ + quality flags | Curator |
| 5 | query.parse |
natural_query | Cosmos 3 vLLM で自然文 → 5 軸タグ変換 | Searcher |
| 6 | search.milvus |
filter_expr, limit | Milvus scalar filter + top-K | Searcher |
| 7 | report.session_summary |
date | DuckDB 集計 | Reporter |
| 8 | report.daily |
date | markdown レポート生成 | Reporter |
このうち今回実動化したのが query.parse と search.milvus の 2 つで、この後のデモに直接使います。残り 6 tool は Pydantic schema + FastAPI endpoint まで skeleton は書いてあって、一旦 backend の実装は 501 Not Implemented を返す状態にしてあります。
自然文 → 5 軸タグ → Milvus filter の 1 サイクル
MCP server (vss-search-demo) の 2 tool、query.parse と search.milvus を実動化します。query.parse は DGX Spark の Cosmos 3 vLLM を呼んで自然文を 5 軸タグの JSON に変換、search.milvus は生成された filter expression で同じ DGX Spark 上の Milvus を叩きます。
MCP に curl を投げてみます。
curl -X POST http://localhost:8090/tools/query.parse \
-H 'Content-Type: application/json' \
-d '{"natural_query":"ラベンダー畑の中の一本道"}'
返ってくる JSON がこれ。
{
"query": "ラベンダー畑の中の一本道",
"parsed": {
"vegetation": "lavender",
"color_dominant": "purple",
"composition": "single_path",
"time_of_day": null,
"season": null
},
"milvus_filter": "vegetation == \"lavender\" and color_dominant == \"purple\" and composition == \"single_path\""
}
null の軸は filter に入れず、値のある 3 軸だけで expression が組まれます。この filter を search.milvus に流し込むと、ヒットが返ってきます。
[
{"video_id": 1705117, "category": "main_lavender_path", "vegetation": "lavender", "composition": "single_path", ...},
{"video_id": 7960985, "category": "main_lavender_path", "vegetation": "lavender", "composition": "single_path", ...},
{"video_id": 8858842, "category": "main_lavender_path", "vegetation": "lavender", "composition": "single_path", ...}
]
返ってきたのは 3 本で、42 本の中から誤ヒットゼロで main_lavender_path だけに絞れています。一方で、main の 8 本のうち composition=single_path タグが付いたのはこの 3 本だけなので、3 軸を and で重ねた filter は思ったより strict でもあります。自然文一発で主軸動画に到達できることと、絞り込みすぎの副作用が同時に見えた形ですね。この strict さがどのくらい効いてくるのかは、次の Recall@5 の実測で数字にして確かめます。
ベースライン Recall@5 を実測する
10 個の query で Recall@5 を計測してみました。Recall@5 は「top-5 に入った正解カテゴリの動画数 ÷ min(5, 正解カテゴリの動画数)」で計算しています。要するに「検索上位 5 件に、入っていてほしい正解動画をどれだけ拾えたか」の割合で、1.00 なら上位 5 件が正解で埋まった状態です。たとえばラベンダー主軸クエリの正解は main_lavender_path の 8 本なので、分母は 5 になります。
| # | Query | Cosmos 3 が返した parsed | Recall@5 |
|---|---|---|---|
| 1 | ラベンダー畑の中の一本道 | veg=lavender / color=purple / comp=single_path | 0.60 |
| 2 | 紫色の花畑 | veg=lavender / color=purple | 1.00 |
| 3 | 花畑を抜ける小道 | veg=lavender / comp=single_path | 0.60 |
| 4 | ラベンダーが咲く農場 | veg=lavender / color=purple | 1.00 |
| 5 | 夏の花畑 | veg=lavender / color=purple / season=summer | 1.00 |
| 6 | 一本道のある風景 | veg=lavender / color=purple / comp=single_path | 0.60 |
| 7 | 黄色いひまわり畑 | veg=sunflower / color=yellow | 0.60 |
| 8 | 麦畑の小道 | veg=wheat / color=brown / comp=single_path | 0.00 |
| 9 | 森の中の小道 | veg=other / comp=single_path | 0.00 |
| 10 | 夜のカフェ | veg=none / comp=interior / time=night | 0.00 |
| — | 平均 | — | 0.540 |
Query 2/4/5 の「紫色の花畑」「ラベンダー農場」「夏の花畑」は Recall@5 = 1.00 で、top-5 が main_lavender_path で埋まりました。一方で query 8/9/10 の境界条件 (麦畑・森・夜のカフェ) は 0.00 です。内訳を見ると、麦畑と夜のカフェは 3 軸の and chain が strict すぎて hit がゼロ、森は森の道の動画自体を filter で拾えているのに、テストセット側の期待カテゴリ設計と噛み合わずスコアが付かない、という 2 種類の落ち方が混じっていました。
Recall@5 = 1.00 が返ってきた query 2 の top-5 に入ってきた動画の代表フレームと、Recall@5 = 0.00 だった query 9「森の中の小道」の該当候補だった動画をそれぞれ 1 枚ずつ載せておきます。

Recall@5 = 1.00 の top-5 の 1 本 (pexels 1705117): 「ラベンダー畑の列を一直線に進み、遠くの地平線まで続く壮大な景色」の temporal_caption と、vegetation=lavender / color_dominant=purple / composition=single_path の 5 軸タグ

Recall@5 = 0.00 の境界条件、query 9「森の中の小道」の 42 動画中の該当候補 (pexels 36527166): vegetation=other / composition=single_path のタグは取れていて filter にもマッチするが、top-5 の枠には入らなかった 1 本
ここで面白いのが、タグ生成では精度 100% だったのに、検索の Recall@5 は 54% にしかならない、という数字のギャップです。個人的にはこのギャップこそが「タグ生成 100% でもメタ filter だけでは足りない」という、続編ネタに繋がるところかなと思っています。dummy vector を real embedding に置き換えて、ANN 検索と組み合わせれば境界条件は伸びるはずですね。
今後試してみたい 6 つの伸びしろ
今回の 42 動画 + 10 query では、素の Cosmos 3 + prompt enum + Milvus メタ filter で Mean Recall@5 = 0.540 が取れました。lavender 主軸はクエリの書き方次第で 1.00 まで取れる一方、境界条件 (麦畑・森・夜のカフェ) は 0.00 で、ここから伸ばすには次の 6 つの伸びしろが残っています。
- drone shot での紫系植物境界判別: close-up ならライラック/ラベンダー/紫陽花/藤/菖蒲を prompt enum で 100% 分離できましたが、空撮画角では素モデルが hydrangea/wisteria/iris を互いに混同します。Cosmos 3 Nano-Reasoner を Brev 8 × H100 で Full SFT して、drone shot 紫系植物の境界判別を補強するのも面白そうですね。
- 細粒度ラベンダー品種識別: Provence / English / Hidcote 等の植物学的専門知識は素モデルの汎用性の範囲外なので、専門データセットで LoRA を作る方向が向いています。
- 複合シーン検索: 「ラベンダー畑で犬と歩く人」のような「複数主体 + 関係性」を含むクエリは、5 軸タグの直積では表現しきれないので、subjects/actions の自然文キャプションを text embedding 化して vector 検索に加える必要があります。
- シーン時間軸推定: 動画内の「2:15 - 2:30 の特定シーン」を切り出す temporal localization は、今回の 8 フレーム抽出方式ではそもそも解像度が足りていません。VSS 3.2 GA の replay API と組み合わせるか、CoTracker 系の temporal grounding モデルとの併用が候補です。
- 対話的検索の文脈保持: マルチターン会話で「さっきの中で夕焼けっぽいのは?」みたいな絞り込みができるように、Hermes Supervisor に会話履歴と検索履歴を持たせる必要があります。ここは Hermes 本体側の機能を素直に使えばよさそうです。
- Vector 検索補完: 今回 Milvus の
cosmos_embedフィールドには dummy の random vector を入れているので、境界条件で 0.00 になった query 8/9/10 が救えていません。Cosmos-Embed1-448p を自前 inference で動かして real 768d embedding に置き換えると、tag filter でヒットしなかった動画も vector similarity で拾える可能性があります。
まとめ
VSS 3.2 GA Search Profile は現時点で DGX Spark を公式非対応にしていて、dev-profile.sh の hardware profile 表で DGX-SPARK / IGX-THOR / AGX-THOR は Search / lvs の 2 profile に対して hard-block されています。技術的な根っこは DeepStream perception-2d の SBSA arm64 build がまだ整備されていないところにあって、そこは upstream の対応を待つのが現実解かもしれません。一方で、別解の 3 点セットは 42 動画 + 10 query で Mean Recall@5 = 0.540 のベースラインまで通せました。
用途別の環境切り分けとしては、こんな整理が現実的そうです。
| 用途 | 現実解 |
|---|---|
| $0 の 1 台 PoC / エッジタグ生成 | DGX Spark + 素 Cosmos 3 + 自前 Milvus + Hermes MCP (本記事のデモ構成) |
| VSS 3.2 GA Search Profile の本格 PoC | x86 + H100 / L40S / RTX Pro 4500-6000 (Brev の DGX Cloud インスタンス等) |
| Cosmos 3 のタグ生成に集中 | DGX Spark 単体で Cosmos 3 Nano-Reasoner をローカル運用、検索は別環境と分ける |
| 将来の DGX Spark 1 台完結 | DeepStream の SBSA arm64 build が公式供給されるのを待つ、もしくは upstream PR |
ローカル完結で自然文動画検索を組みたい人にとって、Cosmos 3 + 自前 Milvus + Hermes MCP の 3 点セットは、VSS Search Profile の SBSA arm64 対応を待つ間の橋渡しとしてそのまま使える形になっています。この構成をベースに何か作ってみたい場合の参考にしてもらえたらうれしいです。
参考リンク
- VSS docs (latest = 3.2.0)
- GitHub: video-search-and-summarization
- GitHub Issue #111 (DGX Spark 向け -sbsa image variant のメンテナ回答)
- GitHub Issue #384 (three-step upload flow への移行と旧 API の deprecation)
- build.nvidia.com: VSS Blueprint カード
- HuggingFace: nvidia/Cosmos3-Nano
- NVIDIA NIM: Cosmos-Embed1
- Milvus
- Pexels
- GitHub: NousResearch/hermes-agent
- Model Context Protocol






