連載: NMS開発者がLLMブートキャンプで学んだこと
← 前回 | [次回 →]
前回は、unstructured を使って PDF、Word、PowerPoint、HTML のような非構造文書を LLM に読ませる話をしました。
PDF からテキストを取り出す。
表を Markdown にする。
画像化されたページを Vision model に読ませる。
ページ番号や文書種別を metadata として残す。
そこまで進めると、次に見えてくるのは「テキスト文書だけでは現場の情報を拾いきれない」という問題です。
NMS やネットワーク運用の現場では、重要な情報が必ずしもテキストファイルに入っているとは限りません。
障害対応会議の音声。
Zoom や Teams の録画。
監視画面のスクリーンショット。
構成図。
ベンダー資料の画像。
実績説明の動画。
つまり、現場の知識は最初から multimodal です。
今回のテーマは、Whisper と Multimodal 処理を使って、音声・画像・動画を LLM/RAG に渡せる形に変換することです。
背景
授業では、Multimodal の実習が中心でした。
最初に触ったのは画像です。
CLIP を使って画像とテキストを同じベクトル空間で扱う。
Vision model に画像を読ませて caption を作る。
チャート画像から数値を JSON として復元する。
その後、Whisper を使って音声をテキストに変換し、最後に動画をフレームと音声に分解しました。
プロジェクトでは、これらをひとつの流れにまとめていました。
PDF / image / audio / video
-> extraction
-> caption or transcript
-> Document
-> FAISS
-> multimodal RAG
-> LLM-as-Judge
この流れを見たとき、個人的にはかなり実務に近いと感じました。
なぜなら、NMS の障害対応でも同じことが起きるからです。
アラームログだけを見ても分からない。
Runbook だけを見ても分からない。
会議で誰かが言った一言に重要な判断が残っている。
監視画面のキャプチャにしか出ていない異常値がある。
構成図を見ないと影響範囲が分からない。
こういう情報を LLM に扱わせるには、単に「RAG を作る」だけでは足りません。
音声、画像、動画を一度 LLM が扱いやすい Document に変換する必要があります。
Whisperで音声をテキストにする
まず分かりやすかったのは、Whisper による音声文字起こしです。
授業資料では、会議音声として次のような mp3 が用意されていました。
meeting_revenue.mp3
meeting_product.mp3
meeting_hiring.mp3
それぞれ、売上、製品、採用に関する会議発話です。
LangChain では OpenAIWhisperParser を使って、音声ファイルを Document に変換できます。
from langchain_community.document_loaders.parsers.audio import OpenAIWhisperParser
from langchain_core.document_loaders.blob_loaders import Blob
def transcribe_audio(audio_path: str) -> dict:
parser = OpenAIWhisperParser()
blob = Blob.from_path(audio_path)
docs = list(parser.lazy_parse(blob))
text = " ".join(d.page_content for d in docs).strip()
return {
"source": Path(audio_path).name,
"text": text,
"n_chunks": len(docs),
}
最初は「音声をテキスト化できたら終わり」と思っていました。
しかし、実務で考えると、ここはまだ入口です。
会議音声をそのまま長い transcript にしても、RAG では使いにくいです。
検索しにくい。
話題が混ざる。
誰が何を決めたのか分からない。
重要な action item が埋もれる。
そこで次に必要になるのが、transcript の chunking です。
授業では RecursiveCharacterTextSplitter を使って、音声文字起こしを RAG 用の Document に分割しました。
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
def chunk_audio_transcript(
transcript: dict,
chunk_size: int = 300,
chunk_overlap: int = 50,
) -> list[Document]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(transcript["text"])
docs = []
for i, chunk in enumerate(chunks):
if not chunk.strip():
continue
docs.append(
Document(
page_content=chunk,
metadata={
"source": transcript["source"],
"element_type": "audio_chunk",
"chunk_index": i,
},
)
)
return docs
ここで重要なのは、element_type を残すことです。
PDF の本文なのか。
画像 caption なのか。
会議音声なのか。
動画の音声なのか。
RAG に入れる時点で全部ただのテキストにしてしまうと、あとで LLM が根拠の性質を区別できません。
会議発話は、決定事項や発言意図を読むために使う。
PDF の表は、数値や正式な仕様の根拠として使う。
画像 caption は、画面や図の状況を補足するために使う。
この区別を metadata に残す設計が、かなり大事だと感じました。
音声認識だけでは会議録にならない
Whisper は便利ですが、Whisper だけで会議録が完成するわけではありません。
音声認識は、あくまで「音声を文字にする」処理です。
会議録に必要なのは、その後です。
発言内容を要約する
決定事項を抽出する
Action item を抽出する
担当者と期限を整理する
未決事項を分ける
次回確認事項を残す
この部分は LLM の仕事になります。
例えば、障害対応会議なら次のような出力が欲しくなります。
障害概要:
- Tokyo DC の edge-router-01 で BGP_SESSION_DOWN が発生
決定事項:
- 一次対応は BGP peer 状態確認を優先
- メンテナンス作業との関連を確認
Action items:
- NOC: interface error count を確認
- Network team: 直近の config change を確認
- Vendor team: 再発時のログ取得手順を確認
未決事項:
- root cause は未確定
- 再発防止策は次回会議で決定
ここで、過去に学んだ structured output や Pydantic がつながってきます。
自然文の会議録も便利ですが、業務システムに渡すなら JSON のほうが扱いやすいです。
class MeetingActionItem(BaseModel):
owner: str
task: str
due_date: str | None
priority: Literal["high", "medium", "low"]
Whisper で transcript を作り、LLM で構造化する。
この二段構えにすると、会議録は単なる文章ではなく、チケットやタスク管理に接続できるデータになります。
NMS の業務なら、障害会議の音声から次のような情報を自動抽出できそうです。
incident_id
affected_device
suspected_cause
temporary_action
permanent_action
owner_team
follow_up_date
ここまでできると、会議録作成というより、障害対応ナレッジの自動蓄積に近づきます。
画像はcaptionにするか、embeddingするか
授業では、画像処理も大きなテーマでした。
画像の扱い方には、大きく二つの方向があります。
ひとつは、Vision model に画像を読ませて caption を作る方法です。
import base64
from langchain_core.messages import HumanMessage
def caption_image(image_path: str) -> str:
raw = Path(image_path).read_bytes()
b64 = base64.b64encode(raw).decode()
msg = HumanMessage(
content=[
{
"type": "text",
"text": "この画像を1〜2文で説明してください。",
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{b64}",
},
},
]
)
return vision.invoke([msg]).content
caption にしてしまえば、通常の text embedding で RAG に入れられます。
これは実装しやすいです。
例えば、監視画面のスクリーンショットを次のような caption にできます。
Tokyo DC のトラフィックダッシュボード。edge-router-01 の inbound traffic が急増し、packet loss が 3.2% 表示されている。
この caption を Document として保存すれば、あとで「packet loss が出ている画面は?」と検索できます。
もうひとつは、CLIP のような model を使って、画像自体を embedding する方法です。
授業では openai/clip-vit-base-patch32 を使って、画像とテキストの類似度を計算しました。
from transformers import CLIPProcessor, CLIPModel
MODEL_NAME = "openai/clip-vit-base-patch32"
model = CLIPModel.from_pretrained(MODEL_NAME).eval()
processor = CLIPProcessor.from_pretrained(MODEL_NAME)
画像を 512 次元のベクトルにし、テキスト query も同じ空間に入れて検索します。
def search_by_text(text, image_embeddings, image_paths, k=3):
query_embedding = encode_text_clip(text)
sims = image_embeddings @ query_embedding
top = np.argsort(-sims)[:k]
return [(image_paths[i], float(sims[i])) for i in top]
この考え方はかなり面白いです。
「bar chart」という query でグラフ画像を探す。
「dashboard」という query で画面キャプチャを探す。
「network topology」という query で構成図を探す。
テキストが書かれていない画像でも、画像の意味で検索できます。
ただし、実務で使うなら注意も必要です。
CLIP は万能ではありません。
特に日本語や韓国語の query では、英語の CLIP model だと検索品質が落ちることがあります。
NMS の資料は日本語、英語、製品名、略語が混ざります。
そのため、画像検索を作るなら、次のような設計が現実的だと思いました。
1. 画像そのものの embedding を保存する
2. Vision model で caption を作る
3. caption の text embedding も保存する
4. query に応じて image vector と caption vector を hybrid search する
画像そのものの類似度だけに頼るより、caption も併用したほうが安定しそうです。
チャート画像は要約ではなく構造化する
画像処理で特に実務に効きそうだったのは、チャート画像の復元です。
授業では、棒グラフ画像を Vision model に渡し、カテゴリと値を JSON として取り出す実習がありました。
ただ「この画像は売上グラフです」と caption するだけでは、業務には少し弱いです。
欲しいのは、次のような構造化データです。
{
"title": "Quarterly Revenue",
"unit": "billion KRW",
"items": [
{"label": "Q1", "value": 120},
{"label": "Q2", "value": 138},
{"label": "Q3", "value": 162},
{"label": "Q4", "value": 188}
]
}
NMS に置き換えると、監視画面のグラフやレポート画像から次のような情報を取り出したくなります。
interface_name
metric_name
peak_value
average_value
time_range
threshold_exceeded
例えば、CPU 使用率のグラフが画像として残っている場合、人間は見れば分かります。
しかし RAG に入れるなら、caption だけでは足りないことがあります。
悪い例:
CPU 使用率のグラフです。
良い例:
edge-router-01 の CPU 使用率は 14:05 から上昇し、14:18 に 92% でピーク。80% 閾値を約18分間超過。
このように、画像を「雰囲気」で読ませるのではなく、業務判断に必要な項目に分解することが重要です。
ここは前回の PDF 表処理ともつながります。
表、グラフ、画像、スクリーンショットは、人間にとっては見れば分かる情報です。
しかし LLM/RAG に入れるには、検索可能なテキストか、構造化されたデータに変換する必要があります。
動画はフレームと音声に分ける
動画はさらに面白く、同時に少し面倒です。
動画はひとつのファイルに見えますが、中には少なくとも二つの情報があります。
映像:
- スライド
- 画面
- デモ
- 表情や操作
音声:
- 説明
- 会議発話
- 質疑応答
授業では、OpenCV で動画から一定間隔ごとにフレームを取り出し、Vision model で caption を作りました。
def caption_video_frames(
video_path: str,
out_dir: str,
every_sec: int = 10,
) -> list[Document]:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
n_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = (n_frames / fps) if fps else 0
docs = []
sec = 0
while sec <= duration:
cap.set(cv2.CAP_PROP_POS_MSEC, sec * 1000)
ok, frame = cap.read()
if not ok:
break
path = out_dir / f"frame_t{sec:03d}s.png"
cv2.imwrite(str(path), frame)
caption = caption_image(str(path))
docs.append(
Document(
page_content=caption,
metadata={
"source": Path(video_path).name,
"element_type": "video_frame_caption",
"timestamp_sec": sec,
},
)
)
sec += every_sec
cap.release()
return docs
ここでも metadata が重要です。
timestamp_sec を残しておけば、あとで根拠を動画の何秒目に戻せます。
会議録や障害分析でこれはかなり大切です。
LLM が「15秒付近のスライドに Q4 売上が表示されています」と答えられるなら、人間が確認しやすくなります。
動画の音声は、ffmpeg で分離して Whisper に渡します。
def transcribe_video_audio(video_path: str, audio_out_dir: str) -> dict:
audio_out_dir = Path(audio_out_dir)
audio_out_dir.mkdir(parents=True, exist_ok=True)
audio_path = audio_out_dir / (Path(video_path).stem + ".mp3")
subprocess.run(
[
"ffmpeg",
"-y",
"-i",
video_path,
"-vn",
"-acodec",
"libmp3lame",
str(audio_path),
],
check=True,
capture_output=True,
)
result = transcribe_audio(str(audio_path))
result["video_source"] = Path(video_path).name
return result
動画をそのまま LLM に投げるのではなく、次のように分解するイメージです。
earnings_briefing.mp4
-> frame @ 0s caption
-> frame @ 15s caption
-> frame @ 30s caption
-> audio transcript
-> audio chunks
この形にすれば、動画も RAG の材料になります。
NMS の現場なら、次のような使い方が考えられます。
作業録画:
- 操作画面のフレーム caption
- 作業者の説明音声 transcript
- コマンド実行タイミング
障害説明動画:
- 監視画面の異常値
- 説明者が話した原因仮説
- 対応判断の根拠
動画は保存されていても、あとから検索しにくいことが多いです。
「あの会議で誰かが再発防止策を言っていた気がする」
「どのスライドで売上の話をしていたか」
「障害説明動画の中で、packet loss の画面が出たのは何秒目か」
こういう検索ができるようになると、動画はただのアーカイブではなく、検索可能なナレッジになります。
図で見るMultimodal RAGの流れ
ここまでの話を図にすると、次のようになります。
この図で伝えたいのは、音声・画像・動画をそのまま RAG に入れるわけではない、という点です。
音声は Whisper で transcript にする。
画像は Vision model で caption や OCR 結果にする。
動画は frame caption と audio transcript に分ける。
そのうえで、すべてを Document + metadata にそろえます。
NMS の障害対応で考えると、会議音声、監視画面キャプチャ、作業録画、Runbook を同じ incident bundle として検索できるようになります。
つまり Multimodal RAG の価値は、画像や音声を直接「見せる」ことではなく、あとから障害原因・判断・担当・対応手順を追える形に変換するところにあります。
すべてをDocumentに統合する
プロジェクトで一番学びが大きかったのは、最終的にすべてを Document に統合する部分でした。
PDF の表。
PDF ページ OCR。
画像 caption。
音声 chunk。
動画 frame caption。
動画 audio chunk。
これらを同じリストに入れます。
Document(
page_content="...",
metadata={
"source": "...",
"element_type": "audio_chunk",
"chunk_index": 0,
},
)
モダリティは違っても、LLM/RAG に渡す段階では次の形にそろえます。
page_content:
LLM が読めるテキスト
metadata:
source
element_type
page_number
timestamp_sec
chunk_index
frame_path
これはかなり実務的な設計だと思いました。
RAG の index に入れる前に、すべてを同じ抽象にそろえる。
ただし、metadata で出自は失わない。
このバランスが大事です。
もし全部をただの文字列にしてしまうと、LLM は次の違いを区別できません。
正式な PDF 表に書かれた数値
会議中の発言
動画フレームを caption した推測
画像 OCR で読んだ文字
実務では、この違いは重要です。
障害対応で「正式な手順書に書かれていること」と「会議で誰かが仮説として言ったこと」は、同じ重みでは扱えません。
そこで授業のプロジェクトでは、検索結果を element_type ごとに分けてプロンプトに入れていました。
### 表 (PDF)
...
### 画像キャプション
...
### 会議発話
...
### ビデオフレーム
...
System message でも、モダリティごとの性質を LLM に伝えます。
表は定量情報を優先する。
発話は発言者の意図や決定を読む。
フレームは視覚情報として扱う。
根拠が足りない場合は確認不可と言う。
これは、ただ context を詰め込むよりずっと良い設計です。
LLM に渡す context は、量だけでなく、意味づけが重要です。
検索にも重みづけが必要になる
Multimodal RAG では、検索結果の混ざり方も問題になります。
例えば「Q4 売上は?」という質問なら、PDF の表や動画音声が強い根拠になります。
一方、「動画の最初に何が表示されていたか?」という質問なら、video frame caption が重要です。
「採用会議で決まったことは?」なら、audio chunk を優先したいです。
つまり、query によって重要な element_type が変わります。
授業プロジェクトでは、element_type による重みづけ検索の考え方も出てきました。
これは NMS 業務でも使えそうです。
障害原因を聞かれた:
runbook, 過去障害報告, 会議発話を重視
数値を聞かれた:
表, グラフ復元データ, メトリクスログを重視
画面状態を聞かれた:
screenshot caption, video frame caption を重視
作業手順を聞かれた:
PDF 手順書, 作業録画 audio, runbook を重視
RAG は「似ている文書を探す」だけではなく、質問の種類に応じて根拠の種類を選ぶ設計が必要になります。
ここは、以前学んだ query classification や Agent workflow とつながります。
質問を分類し、検索対象や重みを変える。
それでも足りなければ、Tool Calling で NMS API を呼ぶ。
回答後は LLM-as-Judge で根拠に忠実かを確認する。
少しずつ、個別に学んだ部品がつながってきました。
5分で試す小さなプロジェクト: NMS向けMultimodal Document検索を作る
最後に、5〜15分で動かせる小さな実習を作ってみます。
ここでは Whisper や Vision API を直接呼びません。
代わりに、すでに Whisper で文字起こしされた会議発話、Vision model で caption 化された監視画面、動画フレーム caption があると仮定します。
実務ではこの前段に Whisper、Vision、OpenCV、ffmpeg が入ります。
今回の最小例では、それらの結果を Document + metadata にそろえ、質問に応じて根拠を検索する部分だけを確認します。
外部パッケージ、サーバー、DB、Docker、OpenAI API Key は不要です。
#nms_multimodal_mini_rag.py
from __future__ import annotations
from dataclasses import dataclass
import re
import sys
from typing import Iterable
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
@dataclass
class Document:
page_content: str
metadata: dict
DOCUMENTS = [
Document(
page_content=(
"障害対応会議の発話。Tokyo DC の edge-router-01 で "
"BGP_SESSION_DOWN が発生。一次対応として BGP peer 状態、"
"interface error、直近の config change を確認する。"
),
metadata={
"source": "incident_meeting.mp3",
"element_type": "audio_chunk",
"chunk_index": 0,
},
),
Document(
page_content=(
"会議の決定事項。NOC は interface error count を確認し、"
"Network team は直近の config diff を確認する。Vendor team は"
"再発時のログ取得条件を整理する。"
),
metadata={
"source": "incident_meeting.mp3",
"element_type": "audio_chunk",
"chunk_index": 1,
},
),
Document(
page_content=(
"監視ダッシュボード画像。edge-router-01 の packet loss が 3.2%、"
"CPU 使用率が 88%、BGP alarm が critical と表示されている。"
),
metadata={
"source": "nms_dashboard.png",
"element_type": "image_caption",
"page_number": 1,
},
),
Document(
page_content=(
"作業録画 15 秒付近のフレーム。Tokyo DC の topology 画面で "
"edge-router-01 と upstream-router-02 の間に警告アイコンが表示されている。"
),
metadata={
"source": "operation_recording.mp4",
"element_type": "video_frame_caption",
"timestamp_sec": 15,
},
),
Document(
page_content=(
"Runbook 抜粋。BGP_SESSION_DOWN の一次確認手順は、"
"1. peer 状態確認、2. interface error 確認、"
"3. 直近 config change 確認、4. upstream 側への影響確認。"
),
metadata={
"source": "bgp_runbook.pdf",
"element_type": "runbook",
"page_number": 12,
},
),
]
BASE_WEIGHTS = {
"runbook": 1.3,
"audio_chunk": 1.0,
"image_caption": 1.0,
"video_frame_caption": 0.9,
}
def tokenize(text: str) -> list[str]:
return re.findall(r"[A-Za-z0-9_./%-]+|[一-龥ぁ-んァ-ンー]+", text.lower())
def query_weight(query: str, element_type: str) -> float:
q = query.lower()
weight = BASE_WEIGHTS.get(element_type, 1.0)
if any(word in q for word in ["手順", "一次対応", "runbook", "確認"]):
if element_type == "runbook":
weight += 0.7
if element_type == "audio_chunk":
weight += 0.2
if any(word in q for word in ["画面", "packet", "loss", "cpu", "表示"]):
if element_type == "image_caption":
weight += 0.7
if element_type == "video_frame_caption":
weight += 0.4
if any(word in q for word in ["誰", "担当", "決定", "action", "todo"]):
if element_type == "audio_chunk":
weight += 0.8
return weight
def score_document(query_tokens: Iterable[str], doc: Document) -> float:
doc_tokens = tokenize(doc.page_content)
if not doc_tokens:
return 0.0
hits = sum(doc_tokens.count(token) for token in query_tokens)
unique_hits = len(set(query_tokens) & set(doc_tokens))
raw_score = hits + unique_hits * 0.5
return raw_score * query_weight(" ".join(query_tokens), doc.metadata["element_type"])
def search(query: str, k: int = 3) -> list[tuple[float, Document]]:
query_tokens = tokenize(query)
ranked = [(score_document(query_tokens, doc), doc) for doc in DOCUMENTS]
ranked = [item for item in ranked if item[0] > 0]
return sorted(ranked, key=lambda item: item[0], reverse=True)[:k]
def answer(query: str) -> str:
hits = search(query)
if not hits:
return "提供された資料では確認できません。"
lines = [f"質問: {query}", "", "検索された根拠:"]
for score, doc in hits:
meta = doc.metadata
location = meta.get("page_number") or meta.get("timestamp_sec") or meta.get("chunk_index")
lines.append(
f"- [{meta['element_type']}] {meta['source']} ({location}) "
f"score={score:.2f}: {doc.page_content}"
)
lines.extend(["", "NMS向け整理:"])
if "担当" in query or "action" in query.lower():
lines.append("- NOC: interface error count を確認する。")
lines.append("- Network team: 直近の config diff を確認する。")
lines.append("- Vendor team: 再発時のログ取得条件を整理する。")
elif "画面" in query or "packet" in query.lower():
lines.append("- 監視画面では packet loss 3.2%、CPU 88%、BGP critical alarm が確認できる。")
lines.append("- 動画フレームでは edge-router-01 と upstream-router-02 間の警告が確認できる。")
elif "一次対応" in query or "手順" in query:
lines.append("- BGP peer 状態を確認する。")
lines.append("- interface error を確認する。")
lines.append("- 直近 config change と upstream 側の影響を確認する。")
else:
lines.append("- 関連情報を incident bundle として保存し、後続分析に使う。")
return "\n".join(lines)
def main() -> None:
queries = [
"BGP_SESSION_DOWN の一次対応手順は?",
"監視画面では packet loss と CPU はどう見えている?",
"担当チームごとの action item は?",
]
for query in queries:
print("=" * 80)
print(answer(query))
if __name__ == "__main__":
main()
実行方法は次の通りです。
python nms_multimodal_mini_rag.py
実行すると、質問ごとに参照された element_type と source が表示されます。
================================================================================
質問: BGP_SESSION_DOWN の一次対応手順は?
検索された根拠:
- [runbook] bgp_runbook.pdf (12) score=3.00: Runbook 抜粋...
- [audio_chunk] incident_meeting.mp3 (0) score=1.80: 障害対応会議の発話...
NMS向け整理:
- BGP peer 状態を確認する。
- interface error を確認する。
- 直近 config change と upstream 側の影響を確認する。
この小さな例で確認したいのは、検索アルゴリズムの精度ではありません。
ポイントは、Whisper や Vision の結果を最終的に同じ Document 構造へそろえつつ、element_type と source を失わないことです。
この設計にしておくと、障害チケット画面で「この回答の根拠は Runbook なのか、会議発話なのか、監視画面なのか」を分けて表示できます。
NMS 開発者の立場では、この出自の区別がかなり重要です。
正式な手順書に書かれていることと、会議中の仮説と、監視画面から読み取った状況を同じ重みで扱うと危険だからです。
NMS業務にどうつなげるか
今回の学びを NMS/SNMP の業務に置き換えるなら、最初に作りたいのは「障害対応会議の自動ナレッジ化」です。
イメージは次のようなパイプラインです。
incident meeting audio
-> Whisper transcript
-> chunking
-> LLM summary
-> action item extraction
-> incident id / device id linking
-> ticket update draft
-> searchable knowledge base
例えば、障害対応後の会議音声から、次のような JSON を作ります。
{
"incident_id": "INC-2026-0529-001",
"summary": "Tokyo DC の edge-router-01 で BGP セッション断が発生し、一次対応として peer 状態と interface error を確認した。",
"affected_devices": ["edge-router-01"],
"suspected_causes": ["直近の config change", "上位 peer の不安定化"],
"actions": [
{
"owner": "NOC",
"task": "interface error count を確認する",
"priority": "high"
},
{
"owner": "Network team",
"task": "直近の config diff を確認する",
"priority": "high"
}
],
"open_questions": [
"再発防止策は未確定",
"ベンダーへのエスカレーション要否を確認"
]
}
これをそのままチケット更新案にできます。
さらに、監視画面のキャプチャや作業録画も組み合わせます。
incident bundle:
alert log
NMS API snapshot
meeting transcript
dashboard screenshot caption
operation video frame caption
runbook references
この bundle を RAG に入れれば、後日こんな質問ができます。
この障害で最初に疑われた原因は何か。
誰が config change を確認することになったか。
packet loss が画面に出ていたのはいつか。
再発防止策は決定済みか。
過去に同じ装置で似た障害はあったか。
このあたりは、単なる会議録自動生成より実務価値が高いと思います。
運用現場では、会議録をきれいに書くこと自体が目的ではありません。
あとから原因、判断、対応、未決事項を追えることが大事です。
Whisper と Multimodal RAG は、そのための材料を増やしてくれます。
まとめ
今回の 授業で学んだことは、LLM に渡せる情報源が一気に広がる感覚でした。
これまで扱ってきたのは、主にテキストでした。
ログ、FAQ、Runbook、PDF。
しかし現場には、それ以外の情報がたくさんあります。
音声、画像、動画、構成図、画面キャプチャ、会議録。
Whisper は、音声を LLM が扱えるテキストに変換してくれます。
Vision model は、画像や動画フレームを説明文や構造化データに変換してくれます。
CLIP は、画像とテキストを意味で検索する考え方を見せてくれます。
OpenCV と ffmpeg は、動画をフレームと音声に分解する実務的な道具になります。
ただし、今回もやはり「モデルを呼べば終わり」ではありませんでした。
音声は chunking が必要です。
画像は caption の品質が重要です。
動画は timestamp を残す必要があります。
会議録は要約だけでなく action item に分解する必要があります。
そして、すべての情報には source と element_type を残す必要があります。
今回の学びを一言でまとめるなら、次のようになります。
Multimodal RAG は、音声・画像・動画をそのまま扱う技術ではなく、
それぞれを業務で検索・検証できる形に変換する設計である。
NMS 開発者として見ると、この領域はかなり可能性があります。
障害会議の自動要約。
作業録画の検索。
監視画面キャプチャのナレッジ化。
構成図と Runbook の横断検索。
障害対応の action item 抽出。
ここまで来ると、LLM はチャット UI の中だけにいるものではなくなります。
運用現場に散らばっている情報を、あとから検索できる知識に変える道具になります。
次回は、その知識をさらに「関係」として扱う GraphRAG に進みます。
通常の RAG が文書を探すものだとすれば、GraphRAG は装置、障害、原因、対応、担当者のつながりを見に行く考え方です。
NMS や障害分析では、かなり相性がよさそうなテーマです。
