🏢

「検索」をやめて「組織図」を作ったら、長尺動画RAGが劇的に賢くなった話

に公開

「検索」をやめて「組織図」を作ったら、長尺動画RAGが劇的に賢くなった話

〜 Google ADK と Vectorless RAG で実装する「階層型エージェント」〜

https://github.com/sunyeul/video-index

プロローグ:深夜のオフィスと、文脈を見失ったAI

深夜2時。オフィスの空調の音だけが響く中、私はモニターに映るログを睨みつけていた。

「違う、そうじゃないんだ……」

手元のマグカップから立ち上るコーヒーの湯気が、ため息と共に揺れる。
私が作っていたのは、社内勉強会の動画(30分)を対象にしたQ&Aボットだ。従来の「Vector RAG」のアプローチ——つまり、文字起こしテキストを一定の長さでぶつ切りにし(チャンク化)、ベクトル化してデータベースに放り込む手法——を採用していた。

ユーザー(同僚)からの質問はこうだ。
「この動画の結論として、マイクロサービス化の最大のデメリットは何だと言っている?」

私のボットが自信満々に返した答えはこれだ。

「動画の15分30秒付近で、『マイクロサービスはデプロイが独立して行えるのがメリットです』と述べています。また、25分付近では『複雑性が増す』という発言もあります。」

……断片的すぎる。
「結論として」と聞いているのに、ボットは動画のあちこちから「マイクロサービス」という単語が含まれる断片(チャンク)を拾い集めてきただけだ。そこには 「文脈」 がない。動画の構成——序論、本論、そして結論——という 「構造」 が、ベクトル化された瞬間に失われてしまっているのだ。

「動画はただのテキストの羅列じゃない。起承転結という『構造』があるんだよ……」

私は椅子に深く沈み込んだ。必要なのは、キーワード検索(Retrieve)じゃない。人間が本を読むときのように、目次を見て、章を選び、必要な節へと降りていく 「探索(Navigation)」 なのではないか?

第1章:出会い — Vectorless RAG と 階層型タスク分解

翌日、解決策を求めて技術記事の海を彷徨っていた私は、2つの興味深い概念に出会った。

一つは、Vectorless RAG (PageIndex) というアプローチ。
Embedding(ベクトル化)に頼らず、データを「意味のあるツリー構造」として保持し、エージェントにその構造を探索させるという考え方だ。

もう一つは、Google Agent Development Kit (ADK) のドキュメントにあった "Hierarchical Task Decomposition"(階層型タスク分解) というパターン。
親エージェントが複雑なタスクを分解し、子エージェントに委譲(Delegate)する仕組みだ。

この2つが、私の頭の中でカチリと噛み合った。

「待てよ……。動画の『章(Chapter)』や『節(Segment)』そのものを、それぞれ 独立したエージェント にしてしまえばいいんじゃないか?」

動画全体を統括する「社長エージェント」。
その下に、各チャプターを担当する「部長エージェント」。
さらにその下に、具体的なシーンを担当する「現場エージェント」。

これなら、質問が来たときに「社長」が「それは第3章の担当だな」と判断し、「部長」に振る。「部長」はさらに「現場」に振る。
組織図(データ構造)そのものを、エージェントの指揮命令系統にしてしまうのだ。

「これだ。これなら『文脈』を維持できる」

私は急いでエディタ(Cursor)を開いた。

第2章:構造化 — Gemini に「目次」を作らせる

まずは「組織図」の元となるデータを作らなければならない。動画というリニアなタイムラインを、論理的な階層構造に変換する作業だ。

ここで活躍するのが、Gemini 2.5 Flash だ。30分の動画ファイル(mp4)をそのままプロンプトに放り込んでも余裕で処理できる巨大なコンテキストウィンドウと、圧倒的な処理速度を持っている。

私は indexer.py を作成し、Geminiにこう指示した。
「この動画を分析し、ボトムアップで論理的なツリー構造を作ってくれ。最小単位『Segment』、それを束ねるのが『Chapter』だ」

video_index/indexer.py
# ... (前略)
VIDEO_TREE_INDEXER_PROMPT = """
あなたは熟練した**動画コンテンツアナリスト**です。
[目標]
動画コンテンツを構造化された**コンテンツツリー**にインデックス化します。
線形的な動画タイムラインを論理的な階層ノードに変換します。

[ノード構造]
1. **SegmentNode(リーフ)**: 最小の意味単位。特定のシーン、会話のやり取り...
2. **ChapterNode(コンテナ)**: 動画の主要な区分。例:イントロダクション、結論...
"""
# ... (後略)

実行ボタンを押す。数秒後、コンソールに美しいJSONが吐き出された。

{
  "video_title": "マイクロサービスアーキテクチャの光と闇",
  "children": [
    {
      "node_type": "Chapter",
      "title": "導入",
      "children": [...]
    },
    {
      "node_type": "Chapter",
      "title": "マイクロサービスの課題",
      "children": [
        {
          "node_type": "Segment",
          "title": "分散トランザクションの難しさ",
          "time_span": {"start": "15:30", "end": "18:45"}
        },
        ...
      ]
    }
  ]
}

「完璧だ……」
これで、エージェントたちの「配属先」が決まった。

第3章:実装 — データに「人格」を与える (Data as Agent)

ここからがエンジニアリングのハイライトだ。
このJSONデータを、どうやって動的なエージェント群に変換するか?

私は "Data as Agent" というパターンを採用することにした。
Pydanticで定義したデータモデル(ChapterNode, SegmentNode)に、自分自身を担当するエージェントを生成するメソッド (to_agent) を持たせるのだ。

video_index/models.py にコードを書き込んでいく。キーボードを叩く指が軽い。

現場の「Segment Agent」

まずは末端のセグメント。彼らは特定の時間範囲(例: 15:30〜18:45)の映像と音声だけを知っている専門家だ。

video_index/models.py
class SegmentNode(BaseModel):
    # ... (フィールド定義)

    def to_agent(self) -> Agent:
        return Agent(
            model="gemini-2.5-flash",
            name=self.id,
            description=f"「{self.title}」の専門エージェント...",
            instruction=f"""あなたは動画セグメント「{self.title}」の専門エージェントです。
            担当範囲: {self.time_span.start_time}{self.time_span.end_time}
            このセグメントに含まれる情報のみを使用して回答してください。""",
            before_model_callback=AttachVideoToLlmRequestCallback(
                start_time=self.time_span.start_time,
                end_time=self.time_span.end_time
            ),
            # ...
        )

中間管理職の「Chapter Agent」

次にチャプター。ここが重要だ。彼らは自分では詳細を語らない。代わりに、部下(Segment Agent)を 「ツール」 として持っている。

Google ADKの AgentTool を使うと、エージェントを他のエージェントから呼び出せる「関数(Tool)」としてラップできる。これが強力だ。

video_index/models.py
class ChapterNode(BaseModel):
    # ... (フィールド定義)
    children: List[SegmentNode]

    def to_agent(self) -> Agent:
        # 子ノードをエージェント化し、さらにツール化する
        agent_tools = [AgentTool(agent=child.to_agent()) for child in self.children]
        
        return Agent(
            model="gemini-2.5-flash",
            name=self.id,
            # 子エージェントをツールとして持たせる
            tools=agent_tools,
            instruction=f"""あなたは動画チャプター「{self.title}」の専門エージェントです。
            
            ## ツール活用指針
            あなたは各セグメントの専門エージェントをツールとして持っています。
            - **詳細な情報が必要な場合は、必ず該当する子エージェントを呼び出してください**
            - 質問内容が特定の時間範囲に関連する場合、その時間を担当する子エージェントに委譲してください
            """,
            # ...
        )

『詳細な情報が必要な場合は、必ず該当する子エージェントを呼び出してください』……この一行が、自律的な組織運営の肝だ」

私は独り言をつぶやきながら、ルートとなる VideoAnalysisResult にも同様の実装を施した。

動画クリップの自動添付 — コールバックの魔法

さらに、もう一つ重要な実装がある。各セグメントエージェントが呼び出されたとき、自動的に担当時間範囲の動画クリップをLLMに渡す仕組みだ。

ADKの before_model_callback を使えば、LLMリクエストが送信される直前に介入できる。

video_index/callbacks.py
class AttachVideoToLlmRequestCallback:
    def __init__(self, start_time: str, end_time: str):
        self.start_time = start_time
        self.end_time = end_time

    async def __call__(
        self, callback_context: CallbackContext, llm_request: LlmRequest
    ) -> LlmResponse | None:
        # アーティファクトから動画を読み込み
        video_part = await callback_context.load_artifact("uploaded_video")
        
        # 時間範囲をメタデータとして設定
        start_offset = int(self.start_time.split(":")[0]) * 60 + int(
            self.start_time.split(":")[1]
        )
        end_offset = int(self.end_time.split(":")[0]) * 60 + int(
            self.end_time.split(":")[1]
        )
        
        video_metadata = types.VideoMetadata(
            start_offset=f"{start_offset}s",
            end_offset=f"{end_offset}s",
            fps=1,
        )
        
        # LLMリクエストに動画を追加
        llm_request.contents.append(
            types.UserContent(
                parts=[types.Part(
                    inline_data=video_part.inline_data,
                    video_metadata=video_metadata,
                )],
            )
        )

これにより、セグメントエージェントは「テキストの要約」だけでなく、実際の映像と音声を見て回答できるようになった。

「これで完璧だ。エージェントは自分の担当範囲の『生の動画』を見て答えられる」

これで、再帰的なエージェントツリーの完成だ。

第4章:対話 — エージェントたちの「思考の連鎖」を見る

実装を終え、StreamlitでUIを立ち上げる。
画面は2カラムに分かれている。左側には動画プレーヤーとその下にGeminiが生成したツリー構造(構造)。右側にはチャット画面。

私は深呼吸をして、プロローグで失敗したあの質問を投げかけた。

「この動画の結論として、マイクロサービス化の最大のデメリットは何だと言っている?」

チャット入力欄にメッセージを入力し、Enterキーを押す。
画面には「考え中...」というスピナーが表示される。私は固唾を飲んでそれを見守った。


(舞台裏:エージェントたちの会話)

Root Agent (Video_Master) 「(思考)ユーザーは『結論』について聞いている。これは動画の後半、特に『まとめ』のチャプターに関連するはずだ。子エージェントのリストを確認……あった、Chapter_Conclusion だ。こいつを呼び出そう。」

Action: Call Tool Chapter_Conclusion

「よし! 社長が適切な部長を指名した!」

Chapter Agent (Chapter_Conclusion) 「(思考)親から『デメリットの結論』について聞かれた。私の担当範囲には3つのセグメントがある。概要には『複雑性の増大』とあるが、詳細は Segment_Complexity_Discussion が詳しいはずだ。」

Action: Call Tool Segment_Complexity_Discussion

「部長も適切に委譲している……! いけ、現場!」

Segment Agent (Segment_Complexity_Discussion) 「(思考)担当範囲は55:10〜58:00。この部分の動画を再確認します……。(回答)動画内でスピーカーは『最大のデメリットは、システム全体の複雑性が指数関数的に増大し、トラブルシューティングが困難になることだ』と明確に述べています。」

Root Agent 「(最終回答)動画の結論パート(Chapter 5)によると、マイクロサービス化の最大のデメリットは 『システム全体の複雑性が増大し、トラブルシューティングが困難になること』 であると述べられています。」


数秒後、チャット画面に完璧な回答が表示された。
私は思わずガッツポーズをした。

「これだ……これが見たかったんだ!」

断片的なキーワードマッチングではない。
「結論」という構造上の位置を理解し、そこから詳細へとドリルダウンしていく探索。
まるで人間が本を読むときのような振る舞いを、エージェントたちが自律的に行っている。

第5章:技術的考察 — 階層型エージェントの現実とトレードオフ

興奮が冷めやらぬ中、私はエンジニアとしての冷静な視点でこのアーキテクチャを評価し直してみた。魔法のように見えるが、そこには明確なロジックとトレードオフがある。

1. レイテンシの壁

「社長→部長→現場」とリレーするため、どうしても単発のRAGより時間はかかる。私の環境では、回答まで平均5〜8秒ほど。
ここで効いてくるのが Gemini 2.5 Flash だ。もしこれが重量級のモデルだったら、回答まで30秒はかかっていただろう。このアーキテクチャは、高速・低遅延なモデルがあって初めて成立する。

2. コンテキストとトークンコスト

「毎回動画全体を読み込むとお金がかかるのでは?」と思うかもしれない。
しかし、この階層構造では 「情報の隠蔽」 が効いている。

  • 親: 「要約」と「子のリスト」しか持たない。
  • 子: 「自分の担当範囲の詳細」しか持たない。
    これにより、各エージェントのコンテキストサイズは最小限に抑えられ、結果としてコストも抑制できる。

3. Vector RAG との使い分け

この手法(Vectorless)が万能なわけではない。

  • Vector RAG: 「特定のキーワードを含む断片」を、大量のドキュメントの海から高速に見つけ出すのに向いている(Google検索的)。
  • 階層型エージェント: 「全体の文脈を踏まえた回答」や「構造化されたデータ(動画、書籍、コードベース)」を深く理解するのに向いている(専門家への相談的)。

適材適所だ。しかし、構造を持つデータに対しては、この階層型アプローチが圧倒的な強さを発揮するのは間違いない。

エピローグ:Embeddingを使わない未来

窓の外はすっかり明るくなっていた。
マグカップのコーヒーは冷え切っていたが、私の胸は熱かった。

データを「断片(Chunk)」としてではなく、「構造(Structure)」として扱う。
そして、その構造のノード一つ一つに「エージェント(人格)」を宿らせる。

この "Data as Agent" のアプローチは、動画だけに限らないはずだ。
複雑な仕様書、巨大なレガシーコード、あるいは法的な契約書……。
「構造」を持つあらゆるデータが、この手法で「話せる相手」に変わる可能性がある。

「さて……」
私は伸びをして、冷めたコーヒーを飲み干した。

「次は、積読になっている技術書もこの方法でエージェント化してみるか」

私のCursorには、新しいプロジェクトフォルダが作成されようとしていた。


使用技術スタック

  • LLM: Gemini 2.5 Flash (Google Gen AI SDK)
  • Agent Framework: Google Agent Development Kit (ADK)
  • Frontend: Streamlit
  • Architecture: Vectorless RAG / Hierarchical Task Decomposition

Discussion