多模态检索真正值钱的,往往不是「搜到了哪个视频文件」,而是把人带到能核验的那几秒。本文用 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 拿到静音与镜头点,用字幕或转写作为文本证据,吸附成带起止毫秒的切片。检索返回时间范围而不是文件名,界面才能跳转核验。切片结构与供应商无关,转写器、切分阈值、检索实现都可以替换,索引不必推倒重来。先让引用可点、可回放,再考虑要不要上更重的多模态生成。
评论