「あの話どの回だっけ」を検索可能にするまで。197万フレームを見る案を捨てて0.5%に絞った
音声認識にかけて終わりのつもりだった。壊れるのは固有名詞と専門用語だけで、そこがこの番組の価値そのものだった。取り得た構成と、採らなかった理由。
なぜ作ったか
ゆる言語学ラジオとゆるコンピュータ科学ラジオを聴いていると、 「あの話、どの回だっけ」がよく起きます。
困るのは、覚えているのがタイトルではないことでした。 覚えているのは「例え話がやたら面白かった」「あの本の話をしていた」 「たしかポライトネスと言っていた」くらいの断片で、 そこからは探しようがありません。YouTube の検索はタイトルと概要欄しか見ないためです。
724本・549時間。見直して探すには多すぎました。 喋った中身そのものを検索できれば済むはずだ、というのが出発点です。
権利のこと
番組を作っているのは自分ではないので、株式会社pedantic の了承をもらい、 合意のうえで運営しています。ドメイン yururadio.jp も先方が保有しています。 各エピソードの著作権その他の権利は、各権利者に帰属します。
そのため、設計の中で著作権に配慮した制約を組み込んでいます。 例えば文字起こしの全文は公開していません。 検索結果に出るのは、一致した箇所とその前後だけにしています。
SELECT idx, start_sec, COALESCE(text_corrected, text)
FROM segments
WHERE video_id = ? AND (検索条件)
ORDER BY start_sec LIMIT 5
1動画あたり最大5件で、そこから YouTube の該当時刻へ送っています。 文字を読んで終わりではなく、本編に戻ってもらう入口にしたかったからです。 全文が読めてしまうと番組の代わりになってしまうので、そこは意識して線を引きました。
作る順番も、技術の選定より先に許諾の相談から入りました。 精度が上がっても、そこが決まっていないと公開できないままだったので。
想定読者
- 音声・動画を大量に文字起こしして、検索や引用に使おうとしている人
- 音声認識の出力が「だいたい合っている」のに使いづらい理由を知りたい人
- 機械の出力を人が直す工程を、属人化させずに仕組みにしたい人
先に結論
- 音声認識は入口でしかなくて、作業の大半はその後の「直す」と「直した根拠を追える状態に保つ」だった
- 誤りは均等には散らばらない。固有名詞と専門用語に集中していて、そこが番組の価値そのものだった
- 壊れた出力は日本語として自然に読めるので、読み直す検査では見つからない
- 高い方法を全体にかけるのをやめて、確定できなかったものだけ次の段へ送る形にした
最初に考えた、素直な方法
音声認識にかければ終わりだと思っていました。実際にかけると、こうなります。
| 機械が出した文字 | 実際 |
|---|---|
| 四本論 | 資本論 |
| 会議派 | 懐疑派 |
| 携帯論 | 形態論 |
| ゼア公文 | there構文 |
平均の精度は悪くありませんでした。困ったのは、誤りが集中する場所のほうです。 壊れるのが固有名詞と専門用語ばかりで、この番組で聴きたいのはだいたいそこでした。 「資本論」で引いて出てこないなら、自分でも使わないなと思いました。
しかもどれも、日本語としては自然に読めてしまいます。 読み直しても気づけないので、「どう直すか」のほうが主題になっていきました。
取り得たけれど、採らなかった構成
案A: 全部、映像のテロップを見て直す
この番組は要点を映像に焼き込んだテロップで出してくれます。 つまり正解は画面にあるので、最初から全部それで答え合わせすればいい、と最初に考えました。
549時間を1秒1フレームで抜くとして、試算してみました。
1,971,103 フレーム
1枚あたり0.1円と置くと 約20万円
1枚あたり0.3円と置くと 約59万円
趣味で運用しているサイトに出せる額ではありませんでした。処理時間とストレージも別にかかります。 やり方としては正しいと思うのですが、払えないので見送りました。
案B: 人が全部聞き直す
文字起こしの校正は実時間の3〜5倍かかると言われるので、4倍で置いてみます。
549時間 × 4 = 2,190時間
1日8時間で 274日
ほぼ1年、それだけをやることになります。新しい回も毎週増えるので、これも諦めました。
案C: LLM に全文を渡して直させる
いちばん楽な案でしたが、試したら音が潰れて聞き取れなかった箇所を、 文脈から作文して埋めてきました。出てくる文が自然なので、読んでも気づけません。
誤字よりこちらのほうが困ります。検索して出てきた一文が 番組で一度も喋られていない、というのは避けたかったので、丸ごと任せるのはやめました。
案D: 全文検索エンジンを立てる
Elasticsearch などを立てれば、検索は速くも柔軟にもなります。 ただ常時動くサーバーが要るので、毎月の固定費と死活監視を個人で抱え続ける自信がありませんでした。
結局、確実さの順に段を分けた
案Aは正しいけれど高い。案Bは正しいけれど終わらない。案Cは速いけれど嘘が混ざる。
それで、高い方法を全体にかけるのをやめました。 安くて確実な方法から順にかけて、そこで確定できなかったものだけを次の段へ送る形にしています。 高い方法に回る件数を減らしたかった、というだけの理由です。
YouTube ──┬─> 音声 ─> Whisper ─> segments テーブル
└─> 映像 ─> ffmpeg ─> OCR(答え合わせ用)
│
┌────────────────────────────────┘
├─> ① 話者ラベルのスイープ(幻覚の除去) … 書式だけで判定
├─> ② 辞書で無条件置換(known_fixes.csv) … 一度確定した誤りの再利用
├─> ③ LLM で校正(直してよい型を5つに限定) … 作文させない
├─> ④ テロップと照合して確定 … ここだけが高い
└─> ⑤ 確定できないものは要確認キューへ → 人が判定
│
FTS5 索引を作り直す ─> 出荷前の検査5項目 ─> GitHub Release ─> Vercel
結果として、④まで届いたのは全体の0.50%でした。
全 segment 1,305,865
テロップまで見たもの 6,537 (0.50%)
案Aは197万フレームを見る案でしたが、実際に見たのはそのごく一部で済みました。 やっていたことは、要するに高い工程に回る件数を減らすことだったのだと思います。
検索エンジンも同じ考えで、SQLite の FTS5 に寄せました。 DBが1ファイルなので、GitHub Release に置いて Vercel から読めば常駐サーバーが要りません。 日本語は形態素解析ではなく、2文字ずつに分けて索引を作っています。
5つの段が、それぞれ何を解いているか
① 話者ラベルのスイープ
音声認識が、出演していない人の名前を行頭に書いてくることがあります。 文としては壊れていないので、意味を見る検査では引っかかりません。 「短い語+半角スペースが行頭に多発する」という書式のほうで拾って落としています。
② 辞書で無条件置換
一度テロップで確定した誤りは、次からは機械で直せます。 ただし登録の条件を「誤形がコーパスに1件も出てこないこと」に固定しました。 正しい語と衝突しないと分かっているものだけを、無条件に置換しています。
③ LLM で校正
案Cの危なさは分かっているので、範囲を絞って使っています。 「正しく直せ」ではなく「これ以外は直すな」と書きました。 直してよいのは、同音異義+決定的な文脈/重複の削除/辞書に登録済み/ 近くで本人が言い直している/括弧の残骸、の5つだけで、 それ以外は直さずに④へ送ります。精度を上げるより、できることを狭めるほうが効きました。
④ テロップと照合して確定
該当する区間だけを1秒刻みで抜いて、タイル画像にして読んでいます。 ここが一番高いので、①〜③で落としきれなかったものだけを通しています。
⑤ 確定できないものは要確認へ
ここが一番悩みました。 確定できなかったものを黙って原文のまま残すと、画面上は「完璧に校正済み」に見えてしまいます。 なので確定できなかった箇所は台帳に書いて、管理画面に色が付くようにしました。 いまは8,493件が要確認として並んでいます。
出荷前に止める仕組み
130万行を機械で書き換えるので、事故は起きるものとして組みました。 本番へ出す前に5項目の検査を通して、1つでも引っかかったら出荷しません。
[A] 出荷時から変わった segment
[B] 区切りが変わった動画 … 起こし直しで既存の修正が消えていないか
[C] 証拠の中身が無い fix
[D] 今回の変更で新しく入った表記 … 番組が一度も使っていない表記が混ざっていないか
[E] 出荷後に変わった segment のうち、段階の記録が無いもの
実際に止まります。話者ラベルの取りこぼし47件を直したときは、[D]で2回止められました。 消した跡に半角スペースが残っていて、番組が一度も使っていない表記になっていたためです。
数字(2026-09-08 時点)
sqlite> SELECT COUNT(*) FROM videos;
724
sqlite> SELECT COUNT(*) FROM segments;
1305865
sqlite> SELECT SUM(LENGTH(COALESCE(text_corrected,text))) FROM segments;
13846682
sqlite> SELECT stage, COUNT(*) FROM edit_stage GROUP BY 1 ORDER BY 2 DESC;
ai|182980
telop|6537
dict|4056
human|276
label|47
724本・549時間・約1,385万字。DBは400MBです。
segments のうち201,086行に修正が入っています。
stage はどの工程が書き換えたかを記録している列で、
「人がどれだけ関わったか」ではありません。
この5つはどれも人の判断の上で動いています。
辞書の1行1行は人がテロップや概要欄で確かめてから登録していますし(123行)、
LLMに直してよい型を5つに絞ったのも人です。
おわりに
音声認識そのものは、もう難しいところではありませんでした。 手こずったのは、機械が自信満々に書いた自然な日本語のうちどれが嘘かを見つけることと、 高い工程に回す件数をどこまで減らせるかのほうです。
それぞれの段で何を壊して、どう止めたかは、書けたものから順に出していきます。