最近在 Hacker News 上看到一个很有想法的动手项目:有人专门为 GTA 6 的 Extended Look 宣传视频构建了一套语义搜索系统,用户不需要记住原片中的具体台词或描述,而是用一句自然语言就能找到对应的画面片段、字幕内容和时间戳。这类“给视频做 AI 检索”的需求,放在若干年前会非常复杂,但现在借助开源语音识别、文本向量化和向量数据库,已经可以用一个 Mini 项目快速落地。
本文不讨论游戏内容本身,而是把整个实现拆解成一套可以照着敲的教程:视频怎么变成文本、文本怎么切块、句子怎么变成向量、向量怎么存进去、最后又如何用语义搜索找回来。整个过程覆盖语音转录、文本预处理、Embedding、向量检索和排序评估,适合对自然语言处理或 AI 应用开发感兴趣的读者。代码基于主流开源库,环境允许的情况下基本可以直接复制运行。
1. 背景与核心概念
1.1 语义搜索是什么
传统的关键词搜索,核心是“字面匹配”。你在网页里搜“sports car”,系统只会返回同时包含“sports”和“car”这两个词的页面。但存在两个明显问题:
- 同义词无法召回,“跑车”相关的内容可能被漏掉。
- 语义相关但字面不同的句子无法命中。
语义搜索(Semantic Search)是用深度学习模型把文本转换成向量,向量越接近,文本语义就越接近。用户搜“a group of people walking on the beach”,系统会先把这个查询文本也转换成向量,然后到向量库里寻找最相近的句子。即使原文没有完全相同的词,只要能表达相似含义,就会被拉出来。
1.2 视频检索为什么难
视频本身是连续的图像和音频流,搜索引擎无法直接索引“画面内容”和“口头表达”。传统方案只能依赖视频标题、简介、标签、章节信息这些“外部元数据”。一旦视频内容没有配套字幕,用户基本只能靠手动拖动进度条来查找信息。
如果能把视频中的语音转成文本,把文本切分成带时间戳的片段,再对这些片段建立语义索引,那么检索问题就变成了文本检索问题:
原始视频 → 音频提取 → 语音转录 → 文本切块 → 向量化 → 向量数据库 → 查询召回 → 返回时间戳整个链路中最关键的一环不是某个模型有多强,而是数据组织是否合理。后面会看到切块和元数据设计甚至会直接影响检索效果。
1.3 GTA 6 Extended Look 检索场景
GTA 6 的 Extended Look 宣传片包含大量画面细节、场景切换和旁白内容。玩家社区的典型需求是:
- “查一下哪一段展示过夜间的城市街头”
- “找到介绍主角身份的台词”
- “对比两个不同场景出现的车型”
这些需求都有明显的语义意图,非常适合用语义搜索来覆盖。项目可以先用语音识别将视频转录为字幕文本,然后为每句话打上开始时间、结束时间、所属片段编号,最后构建向量索引。
2. 项目目标与架构设计
2.1 功能目标
这个项目会做成一个命令行工具或本地服务,支持以下功能:
- 输入视频文件,自动抽取音频并转录文本。
- 将转录文本切分为适合检索的片段。
- 为每个片段生成向量并写入本地向量数据库。
- 输入一个查询句子,返回最相关的片段、相似度分数和时间戳。
整体目标是:给一段视频建立可被自然语言查询的“语义索引”。
2.2 技术选型
选用技术栈时主要考虑三个约束:上手成本低、可以本地运行、有活跃社区维护。
- 语音转写:OpenAI Whisper。开源、支持多语言、能输出时间戳。
- 文本向量化:sentence-transformers。封装了 Transformers,只需要几行代码就能生成句子向量。
- 向量数据库:Chroma。轻量级、支持本地持久化,适合学习和中小规模项目。
- 可选混合检索:rank-bm25。用于和向量检索做对比,理解两种检索方式的差异。
2.3 项目目录结构
建议按下面结构组织项目,便于后续扩展:
gta6-semantic-search/ ├── data/ │ ├── raw/ │ │ └── gta6_extended_look.mp4 │ └── processed/ │ ├── transcript.json │ ├── chunks.json │ └── vectordb/ ├── src/ │ ├── transcribe.py │ ├── chunk.py │ ├── embed.py │ ├── index.py │ └── search.py └── requirements.txtdata/raw存放原始视频,data/processed存放中间数据和向量库文件。src下每个文件职责单一,方便调试。
3. 环境准备与依赖安装
3.1 基础环境
本文示例以 Python 3.10+ 为主,操作系统可以是 Windows、macOS 或 Linux。建议先创建一个干净的虚拟环境:
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate3.2 安装依赖
在项目根目录创建requirements.txt:
openai-whisper sentence-transformers chromadb rank-bm25 ffmpeg-python然后执行:
pip install -r requirements.txtWhisper 在运行时依赖 FFmpeg。不同操作系统安装方式不同:
# macOS brew install ffmpeg # Ubuntu / Debian sudo apt update && sudo apt install ffmpeg # Windows # 推荐通过 winget 安装:winget install Gyan.FFmpeg版本需要根据你的项目实际情况调整,本文示例以常见稳定版本为例,重点演示配置思路。安装完成后可以验证 FFmpeg:
ffmpeg -version如果能正常输出版本信息,说明基础环境已经 OK。
4. 数据准备:从视频到文本
4.1 基本转录流程
Whisper 支持直接传入视频文件,内部会自动调用 FFmpeg 处理音频流。下面代码会读取视频、输出带时间戳的转录段落。
文件路径:src/transcribe.py
import json import whisper def transcribe_video(video_path: str, model_size: str = "base") -> list[dict]: model = whisper.load_model(model_size) result = model.transcribe(video_path, verbose=False) segments = [] for segment in result["segments"]: segments.append({ "id": segment["id"], "start": round(segment["start"], 2), "end": round(segment["end"], 2), "text": segment["text"].strip() }) return segments if __name__ == "__main__": video_path = "data/raw/gta6_extended_look.mp4" segments = transcribe_video(video_path, model_size="base") output_path = "data/processed/transcript.json" with open(output_path, "w", encoding="utf-8") as f: json.dump(segments, f, ensure_ascii=False, indent=2) print(f"转录完成,共 {len(segments)} 段,结果保存在 {output_path}")4.2 关键参数说明
model_size可选tiny、base、small、medium、large。模型越大,识别准确率越高,但资源消耗也越大。
官方建议如果硬件内存比较紧张,可以先用base验证流程,确认结果满意后再切换到small或medium。verbose=False可以关闭逐句打印,运行界面更干净。
转录结果会保存在transcript.json,每条数据都包含start、end和text三个字段。start是这一句话在视频中开始的时间(秒),end是结束时间。
4.3 转录质量优化
如果视频中有背景音乐、人声混响或角色口音较重,默认模型可能产生错误。几条实际经验:
- 先把视频中的音频抽取成 16kHz 单声道 wav,再交给 Whisper,可以减少格式转换带来的识别损失。
- 对长视频分批转录,避免一次性传入过大文件。
- 如果视频本身有官方字幕或隐藏字幕,优先使用字幕数据,转录仅作为兜底方案。
比如用 FFmpeg 抽取音频:
ffmpeg -i data/raw/gta6_extended_look.mp4 -vn -ac 1 -ar 16000 data/raw/audio.wav然后把transcribe.py中的视频路径改成音频路径即可。
5. 文本切分与数据清洗
5.1 为什么要切分
视频转录出的文本并不适合直接向量化。一个长视频可能包含几百句台词,直接对整个文本生成一个向量,会丢失太多细节,检索时无法定位到具体时间点。正确做法是切分成多个语义完整、长度适中的文本块。
切块需要遵循两个原则:
- 每个块尽量是完整的一句话或几句话,避免从句子中间截断。
- 每个块要能携带时间戳,方便后续定位到具体视频片段。
最简单的方式是直接把 Whisper 的每个 segment 作为一个块。因为 Whisper 默认就会按断句产出 segment,时间戳也是现成的。
5.2 切片实现
文件路径:src/chunk.py
import json from typing import Any def build_chunks(segments: list[dict], window: int = 3) -> list[dict]: chunks = [] text_buffer = [] start_time = segments[0]["start"] end_time = segments[0]["end"] for seg in segments: text_buffer.append(seg["text"]) if len(text_buffer) >= window: end_time = seg["end"] chunk_text = " ".join(text_buffer).strip() chunks.append({ "start": start_time, "end": end_time, "text": chunk_text }) text_buffer = [] start_time = segments[window]["start"] if len(segments) > window else seg["end"] if text_buffer: chunks.append({ "start": start_time, "end": end_time, "text": " ".join(text_buffer).strip() }) return chunks if __name__ == "__main__": with open("data/processed/transcript.json", "r", encoding="utf-8") as f: segments = json.load(f) chunks = build_chunks(segments, window=3) with open("data/processed/chunks.json", "w", encoding="utf-8") as f: json.dump(chunks, f, ensure_ascii=False, indent=2) print(f"生成了 {len(chunks)} 个文本块")这里window=3表示每 3 句话合并成一个块。start_time取块内第一句的开始时间,end_time取块内最后一句的结束时间。检索时返回这个时间范围,用户就能直接跳转。
5.3 清洗与过滤
转录文本中经常有空白、语气词、音乐标记或重复内容。建议在进入 Embedding 前做一次清洗:
import re def clean_text(text: str) -> str: text = text.strip() text = re.sub(r"\[.*?\]", "", text) # 去掉 [音乐] 这类标记 text = re.sub(r"\(.*?\)", "", text) # 去掉 (鼓掌) 这类标记 text = re.sub(r"\s+", " ", text) return text.strip()清洗能减少无关字符对向量表示的干扰,让检索结果更干净。
6. 向量化与索引构建
6.1 选择 Embedding 模型
sentence-transformers提供大量预训练模型。对于包含中文或混合语言的场景,建议选择多语言模型,例如paraphrase-multilingual-MiniLM-L12-v2;如果只处理英文,也可以使用all-MiniLM-L6-v2,它更轻量。
模型选型没有绝对答案。更准确但不一定更快,更小但不一定更差。取决于你的数据规模、服务器内存和检索延迟要求。
6.2 生成向量并写入 Chroma
文件路径:src/index.py
import json import chromadb from sentence_transformers import SentenceTransformer def build_index( chunks_path: str, model_name: str = "paraphrase-multilingual-MiniLM-L12-v2", db_path: str = "data/processed/vectordb" ): with open(chunks_path, "r", encoding="utf-8") as f: chunks = json.load(f) model = SentenceTransformer(model_name) texts = [c["text"] for c in chunks] embeddings = model.encode(texts, normalize_embeddings=True) client = chromadb.PersistentClient(path=db_path) collection = client.get_or_create_collection( name="video_chunks", metadata={"hnsw:space": "cosine"} ) ids = [f"chunk_{i}" for i in range(len(chunks))] metadatas = [ {"start": c["start"], "end": c["end"]} for c in chunks ] collection.add( ids=ids, documents=texts, embeddings=embeddings.tolist(), metadatas=metadatas ) print(f"索引构建完成,共入库 {len(chunks)} 个文本块") if __name__ == "__main__": build_index("data/processed/chunks.json")这里有两个重点需要理解:
normalize_embeddings=True会将向量归一化,配合余弦相似度计算效果更好。metadata中保存了start和end,查询结果可以直接用于视频定位。
Chroma 的PersistentClient会把数据持久化到磁盘目录,下次启动时不需要重新生成索引。
6.3 向量数据库的一些概念
向量数据库本质上解决的是一件事:给定一个目标向量,快速找到最相似的 K 个向量。
Chroma 默认使用 HNSW 索引,适合中小规模数据。GTA 6 宣传片这种场景,通常只有几百到几千条文本块,Chroma 完全够用。如果未来数据量达到千万级,再考虑 Milvus、Qdrant 或 Elasticsearch 向量检索插件等分布式方案。
7. 语义检索与结果排序
7.1 基础向量检索
文件路径:src/search.py
import json import chromadb from sentence_transformers import SentenceTransformer def search( query: str, top_k: int = 5, model_name: str = "paraphrase-multilingual-MiniLM-L12-v2", db_path: str = "data/processed/vectordb" ): model = SentenceTransformer(model_name) client = chromadb.PersistentClient(path=db_path) collection = client.get_collection("video_chunks") query_embedding = model.encode([query], normalize_embeddings=True) results = collection.query( query_embeddings=query_embedding.tolist(), n_results=top_k, include=["documents", "metadatas", "distances"] ) return results def print_results(results): docs = results["documents"][0] metas = results["metadatas"][0] distances = results["distances"][0] for rank, (doc, meta, dist) in enumerate(zip(docs, metas, distances), start=1): start = meta["start"] end = meta["end"] score = 1 - dist # cosine distance 转相似度 print(f"[{rank}] 时间 {start:.2f}s - {end:.2f}s,相似度 {score:.4f}") print(f" {doc}") print() if __name__ == "__main__": query = "夜间的城市街道" results = search(query, top_k=5) print_results(results)7.2 距离与相似度
Chroma 返回的distances是余弦距离,取值范围是 0 到 2 左右。转化为相似度可以用1 - distance,数值越大代表越相似。
这里需要说明的是,paraphrase-multilingual-MiniLM-L12-v2生成的向量余弦相似度绝对值范围不一定是 0 到 1,不同模型会有差异,所以更推荐关注相似度的排名顺序,而不是绝对分数。
7.3 混合检索优化
向量检索擅长语义相关召回,但偶尔会忽略精确的专有名词。比如用户输入“GTA6”,向量模型可能把它理解成“游戏名称”,但如果字幕里只出现过一次“Grand Theft Auto VI”,向量召回未必排在最前。
这种情况下可以做混合检索:用 BM25 跑关键词召回,再用向量跑语义召回,最后将两路结果按分数融合。
代码片段思路如下:
from rank_bm25 import BM25Okapi def bm25_search(chunks, query, top_k=5): tokenized_corpus = [c["text"].split() for c in chunks] bm25 = BM25Okapi(tokenized_corpus) scores = bm25.get_scores(query.split()) ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) return ranked[:top_k]混合检索的具体融合策略需要结合数据调整。一个简单的做法是:取向量检索前 20 条和 BM25 前 20 条,按排名倒数求加权和,再排序输出。虽然朴素,但往往能同时提升精确率和召回率。
8. 完整查询演示与效果评估
8.1 演示流程
假设我们构建好了索引,现在输入几条查询:
python src/search.py输出示例可能如下(具体结果取决于视频内容和转录质量):
[1] 时间 12.35s - 18.20s,相似度 0.8123 夜色下的城市街道,霓虹灯在雨后路面上留下倒影。 [2] 时间 45.10s - 50.66s,相似度 0.7931 车辆驶过商业区,远处建筑群的灯光逐渐亮起。这里的文本只是演示效果,不是真实宣传片内容。关键在于整个链路是通的:输入自然语言,输出文本片段和时间范围。
8.2 如何评估检索效果
语义搜索项目不能只看“能不能跑通”,还要看“召回质量”。建议建立一个小型评测集:
- 准备 20 到 50 条查询问题。
- 人工标注每条问题对应的正确视频片段。
- 对系统输出结果统计 Top 1、Top 5 命中率。
最简单的做法:
| 查询 | 正确片段时间 | Top 5 是否命中 |
|---|---|---|
| 夜晚的城市街道 | 12s - 18s | 是 |
| 主角自我介绍 | 88s - 92s | 是 |
| 出现摩托车的场景 | 201s - 205s | 否 |
如果命中率偏低,优先检查转录质量、切块粒度和嵌入模型。
8.3 常用效果提升手段
- 调整
window大小。窗口越小,定位越精确,但上下文越少;窗口越大,语义越完整,但匹配粒度变粗。 - 使用更大更强的 Embedding 模型,例如
bge-large-zh-v1.5、text-embedding-3-large等,但需要确认部署环境。 - 对转录文本执行纠错,尤其是专有名词。
- 在元数据中加入章节名、场景标签,让检索结果更丰富。
9. 常见问题与排查思路
9.1 常见错误汇总
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
FileNotFoundError: ffmpeg | FFmpeg 未安装或未加入 PATH | 安装 FFmpeg 后重新打开终端验证 |
| 转录结果全是空字符串 | 视频没有音频轨或音频格式异常 | 用 FFmpeg 检查音频流并抽取为 wav |
| 向量检索结果明显不合理 | 切块过大或过小、模型不合适 | 调整切块窗口,换多语言模型 |
| Chroma 启动报错 | 持久化目录被占用或版本冲突 | 删除旧目录重新构建,或升级 chromadb |
| 内存占用过高 | 模型载入和视频转录同时进行 | 分开运行转录和向量化流程 |
9.2 转录不准的排查路径
先确定是“语音识别错误”还是“检索排序错误”。可以单独把transcript.json拿出来看:
python -m json.tool data/processed/transcript.json如果文本本身错误,需要优化音质或换更大的 Whisper 模型;如果文本正确但检索不对,问题在切块和向量模型。
9.3 切块跨句子的问题
Whisper 默认按静音和语义断句,但偶尔也会把两句话拼在一起。如果切块跨了不同的语义内容,向量表示会发生偏移。建议在切块前用标点符号做二次拆分:
import re def split_sentences(text: str) -> list[str]: parts = re.split(r"(?<=[.!?。!?])\s+", text) return [p.strip() for p in parts if p.strip()]这样可以保证每个块内是完整的句子,减少语义污染。
10. 最佳实践与工程建议
10.1 元数据设计要提前
视频检索项目最容易被忽视的是元数据。时间戳只是底线,更完善的元数据应该包括:
- 视频来源、章节名
- 场景类型标签,比如“夜景”“雨天”“追逐”
- 角色名或专有名词
- 转录置信度
元数据越丰富,后续的过滤、排序和界面展示空间就越大。Chroma 的metadata字段是字典结构,一开始就规划好字段命名,后续扩展会省很多事。
10.2 文件命名与版本管理
转录文件、切块文件、向量库文件都属于中间产物,建议按日期或视频版本命名:
data/processed/gta6_extended_look_base_model_transcript.json data/processed/gta6_extended_look_window3_chunks.json当切换不同模型、不同切块参数时,对比实验会更方便。
10.3 检索服务化
命令行工具适合开发调试,如果要给团队或社区使用,可以考虑用 FastAPI 封装成一个 HTTP 接口。
from fastapi import FastAPI app = FastAPI() @app.get("/search") def search_api(q: str, top_k: int = 5): results = search(q, top_k=top_k) return results再用 Uvicorn 启动:
uvicorn app:app --host 0.0.0.0 --port 8000接口化之后,前端播放器可以直接调用接口,跳转到返回的时间戳。
10.4 合规与内容边界
构建视频语义搜索时,要注意版权边界。项目用于技术学习、个人研究或内部信息检索是常见场景,但不要将受版权保护的视频内容进行未授权的再分发或商业利用。如果系统面向公众,建议只提供“定位到时间点”的能力,而不是把视频字幕或画面直接完整展示出来。权限设计上也要遵循最小权限原则,避免未授权访问。
10.5 从 0 到 1 的优先级
如果你准备自己动手实现一遍,建议按以下顺序推进:
- 先用现成字幕文件或人工转录的小样本文本测试语义检索链路。
- 确认检索效果好之后,再接入 Whisper 做完整视频转录。
- 先跑通命令行版本,再考虑接口化和前端展示。
- 最后再做效果评估和参数调优。
这样可以避免一上来就陷入“转录不准 + 检索不准 + 工程复杂”的多重问题。
语义搜索的落地质量,很大程度上取决于数据准备环节。转录文本是否干净、切块粒度是否合理、元数据是否完整,这些往往比模型选择更能决定最终效果。如果视频内容本身是英文,而查询语言是中文,记得选择多语言模型。如果你在这个基础上继续加入 RAG、多模态理解或排行榜式评测,这条技术路线还有很大的扩展空间,动手实验时建议先把最简版本跑通。