多模态检索真正值钱的,往往不是「搜到了哪个视频文件」,而是把人带到能核验的那几秒。本文用 Node.js 把转写、字幕时间轴、镜头与静音边界切成可版本化的切片,入库时保留起止时间与文本证据。检索命中即可跳转回放,换转写器或换模型也不必推倒索引结构。

为什么不要把整段视频塞进模型

很多团队一上来就把两小时录像丢给多模态模型,指望它「看懂」再回答。这条路有三个实打实的问题。

第一,上下文窗口再大,也扛不住冗长、重复、口误和过场。模型会概括,但概括没法点开核验,你也无法向同事证明答案来自哪一句口播。第二,一次喂完整段,费用和耗时随时长上涨,中间失败还得整段重跑,缓存也难以按段落失效。第三,业务真正要的不是「这个文件相关」,而是某段话落在第几分几秒。没有时间戳,检索结果只是一张无法质证的卡片。

所以我把问题改写成:先把视频变成带时间范围的文本切片,检索只打切片;命中后用起止时间去播放器跳转。模型只负责理解短片段,或者先不做生成,只做检索与引用。整段视频留给播放器,不留给提示词。索引流水线要能重跑、能对比版本,而不是某次模型调用的副作用。

切片契约:版本化比「切得更聪明」更重要

切片格式一旦绑死某家向量库的字段,换模型就要迁数据。我更愿意先落一份自己能做版本对比的 JSON。每条切片至少包含:视频编号、切片编号、起止毫秒、文本证据、边界来源、转写或字幕版本。

// slice-schema.js
export function makeSlice({ videoId, startMs, endMs, text, sources, asrVersion }) {
  if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
    throw new Error("endMs must be greater than startMs");
  }
  return {
    id: `${videoId}:${startMs}-${endMs}`,
    videoId,
    startMs,
    endMs,
    durationMs: endMs - startMs,
    text: String(text).replace(/\s+/g, " ").trim(),
    sources,
    asrVersion,
    createdAt: new Date().toISOString(),
  };
}

asrVersion 很关键。同一段视频,不同转写器会给出不同文本,时间轴却往往可以共用。换转写器时只重跑文本字段,不必重切时间边界,也不必改前端跳转逻辑。切点来源也要记下来,方便事后解释为什么在这里断开。

单条切片我倾向控制在十五到九十秒。太短会碎,引用时缺少上下文;太长又变回把整段塞进模型。访谈、录屏、课程的停顿节奏不同,阈值应该分开调,不要写成全局常数。切完把条数、时长分布和空文本比例打出来看一眼,这比追求一次切准更重要。

用 FFmpeg 提取静音与镜头边界

场景切分不必先上深度学习。FFmpeg 的 silencedetect 适合访谈和会议,select='gt(scene,N)' 适合演示录屏和课程。下面的脚本调用本机 ffmpeg,从标准错误输出里解析时间戳。请先确认命令行能直接运行 ffmpeg。

// detect-boundaries.js
import { spawn } from "node:child_process";

function runFfmpeg(args) {
  return new Promise((resolve, reject) => {
    const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
    let stderr = "";
    proc.stderr.setEncoding("utf8");
    proc.stderr.on("data", (chunk) => {
      stderr += chunk;
    });
    proc.on("error", reject);
    proc.on("close", () => resolve(stderr));
  });
}

export async function detectSilence(videoPath, { noiseDb = -30, minDur = 0.5 } = {}) {
  const log = await runFfmpeg([
    "-hide_banner",
    "-i",
    videoPath,
    "-af",
    `silencedetect=noise=${noiseDb}dB:d=${minDur}`,
    "-f",
    "null",
    "-",
  ]);
  const starts = [...log.matchAll(/silence_start: ([\d.]+)/g)].map((m) => Number(m[1]));
  const ends = [...log.matchAll(/silence_end: ([\d.]+)/g)].map((m) => Number(m[1]));
  const n = Math.min(starts.length, ends.length);
  const ranges = [];
  for (let i = 0; i < n; i++) {
    ranges.push({
      type: "silence",
      startMs: Math.round(starts[i] * 1000),
      endMs: Math.round(ends[i] * 1000),
    });
  }
  return ranges;
}

export async function detectScenes(videoPath, { threshold = 0.3 } = {}) {
  const log = await runFfmpeg([
    "-hide_banner",
    "-i",
    videoPath,
    "-vf",
    `select='gt(scene,${threshold})',showinfo`,
    "-an",
    "-f",
    "null",
    "-",
  ]);
  const times = [...log.matchAll(/pts_time:([\d.]+)/g)].map((m) => Number(m[1]));
  return [...new Set(times)]
    .sort((a, b) => a - b)
    .map((t) => ({ type: "scene", atMs: Math.round(t * 1000) }));
}

阈值跟素材有关。噪声底与最小静音时长,对安静演播室和嘈杂现场差很多;镜头阈值也一样,录屏光标闪动有时会被当成切镜。先把原始边界写成 JSON 落盘再调参,避免每次改数字都重解整段视频。检测结果只是候选切点,不是最终切片。最终切片还要和字幕对齐,否则画面切在半句话上,跳转体验会差。

字幕对齐:把时间轴变成可引用文本

如果视频已有 SRT 或 WebVTT,优先用它,不要一上来就重新做语音识别。字幕本身就是带时间戳的证据链,人工校对过的更是如此。解析时不必引入不确定的封装,标准字幕时间轴用几行代码就能读。

// align-subs.js
import { readFile, writeFile } from "node:fs/promises";
import { makeSlice } from "./slice-schema.js";

function toMs(ts) {
  const clean = ts.replace(",", ".").replace(/[^\d:.]/g, "");
  const [h, m, rest] = clean.split(":");
  return Math.round((Number(h) * 3600 + Number(m) * 60 + Number(rest)) * 1000);
}

export function parseSrt(raw) {
  const blocks = raw.replace(/\r\n/g, "\n").trim().split(/\n\n+/);
  return blocks
    .map((block) => {
      const lines = block.split("\n");
      const timeLine = lines.find((l) => l.includes("-->"));
      if (!timeLine) return null;
      const [start, end] = timeLine.split("-->").map((s) => s.trim());
      const text = lines
        .filter((l) => !/^\d+$/.test(l.trim()) && !l.includes("-->"))
        .join(" ")
        .replace(/<[^>]+>/g, "")
        .trim();
      return { startMs: toMs(start), endMs: toMs(end), text };
    })
    .filter((c) => c && c.text && c.endMs > c.startMs);
}

function mergeCues(cues, { maxMs = 60000, maxGapMs = 800 } = {}) {
  const merged = [];
  for (const cue of cues) {
    const last = merged[merged.length - 1];
    if (last && cue.startMs - last.endMs <= maxGapMs && cue.endMs - last.startMs <= maxMs) {
      last.endMs = cue.endMs;
      last.text = `${last.text} ${cue.text}`;
    } else {
      merged.push({ ...cue });
    }
  }
  return merged;
}

function snapToBoundaries(cues, sceneAts, silenceRanges, windowMs = 400) {
  const cuts = new Set(sceneAts.map((s) => s.atMs));
  for (const s of silenceRanges) cuts.add(s.startMs);
  const cutList = [...cuts].sort((a, b) => a - b);

  function nearest(t) {
    let best = null;
    for (const c of cutList) {
      const d = Math.abs(c - t);
      if (d <= windowMs && (best === null || d < Math.abs(best - t))) best = c;
    }
    return best;
  }

  return cues.map((cue) => {
    const startMs = nearest(cue.startMs) ?? cue.startMs;
    const endMs = nearest(cue.endMs) ?? cue.endMs;
    const snapped = startMs !== cue.startMs || endMs !== cue.endMs;
    return {
      startMs,
      endMs: endMs > startMs ? endMs : cue.endMs,
      text: cue.text,
      sources: snapped ? ["subtitle", "boundary-snap"] : ["subtitle"],
    };
  });
}

export async function buildIndex({ videoId, srtPath, scenes, silences, asrVersion, outPath }) {
  const cues = parseSrt(await readFile(srtPath, "utf8"));
  const snapped = snapToBoundaries(mergeCues(cues), scenes, silences);
  const slices = snapped.map((c) => makeSlice({ videoId, asrVersion, ...c }));
  const payload = { videoId, asrVersion, sliceCount: slices.length, slices };
  await writeFile(outPath, JSON.stringify(payload, null, 2));
  return slices;
}

对齐策略要克制:只在四百毫秒内把字幕起止吸附到静音或镜头点,不强行按镜头重切句子。吸附失败就保留字幕原时间。这样说话和画面大致同步,又不会因为场景误报把一句话撕成两半。相邻字幕若间隔很短、合并后不超过一分钟,可以拼成一条切片,减少检索碎片。

没有现成字幕时,再用带段级时间戳的转写结果,映射到同一套 makeSlice。不要把某家接口的字段名写进索引,后面换转写器会痛。媒体文件、边界文件、字幕或转写、切片索引分开存放,用视频编号和版本号关联。

检索命中必须带回可跳转的时间范围

索引落地后,检索层只面对切片。下面用倒排和词面匹配,故意不绑任何搜索产品。之后换成嵌入检索也可以,只要查询结果仍返回起止时间和文本证据。评分只用来排序,不要把它当成准确率。

// search.js
import { readFile } from "node:fs/promises";
import { detectScenes, detectSilence } from "./detect-boundaries.js";
import { buildIndex } from "./align-subs.js";

function tokenize(s) {
  return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 1);
}

export async function loadIndex(path) {
  const { slices } = JSON.parse(await readFile(path, "utf8"));
  const df = new Map();
  for (const sl of slices) {
    for (const t of new Set(tokenize(sl.text))) {
      df.set(t, (df.get(t) || 0) + 1);
    }
  }
  return { slices, df, n: slices.length };
}

export function search(index, query, { k = 5 } = {}) {
  const qTokens = tokenize(query);
  return index.slices
    .map((slice) => {
      const tf = tokenize(slice.text);
      let score = 0;
      for (const t of qTokens) {
        const f = tf.filter((x) => x === t).length;
        if (!f) continue;
        const idf = Math.log((index.n + 1) / ((index.df.get(t) || 0) + 1));
        score += (1 + Math.log(f)) * idf;
      }
      return { score, slice };
    })
    .filter((x) => x.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, k)
    .map(({ score, slice }) => ({
      id: slice.id,
      videoId: slice.videoId,
      startMs: slice.startMs,
      endMs: slice.endMs,
      text: slice.text,
      score: Number(score.toFixed(4)),
      playback: { startSec: slice.startMs / 1000, endSec: slice.endMs / 1000 },
    }));
}

const videoPath = process.argv[2];
const srtPath = process.argv[3];
const query = process.argv[4];

if (videoPath && srtPath && query) {
  const videoId = "demo";
  const asrVersion = "human-srt-v1";
  const [silences, scenes] = await Promise.all([
    detectSilence(videoPath),
    detectScenes(videoPath),
  ]);
  await buildIndex({
    videoId,
    srtPath,
    scenes,
    silences,
    asrVersion,
    outPath: "index.json",
  });
  const index = await loadIndex("index.json");
  console.log(JSON.stringify(search(index, query), null, 2));
}

同目录下用 Node.js 18+ 运行:node search.js talk.mp4 talk.srt 回滚方案。命中项里的 playback.startSec 赋给 HTMLMediaElement.currentTime 即可跳转,并在 endSec 暂停或提示下一段。界面同时展示 text,让人对照口播。不要在检索服务里塞进整段视频二进制:索引是文本加时间,媒体走对象存储或本地文件,引用链接才能稳定转发。

换识别模型只更新文本;换场景阈值只更新边界再吸附;前端契约始终是起止秒。这样流水线才经得起换零件。向量库、重排序、多模态生成都可以后加,但不要让它们成为切片能否被引用的前提。

总结

不要把整段视频当作模型的最小输入。先用 FFmpeg 拿到静音与镜头点,用字幕或转写作为文本证据,吸附成带起止毫秒的切片。检索返回时间范围而不是文件名,界面才能跳转核验。切片结构与供应商无关,转写器、切分阈值、检索实现都可以替换,索引不必推倒重来。先让引用可点、可回放,再考虑要不要上更重的多模态生成。