B 站视频转文字,单独做一次不难,难的是处理完之后的“复用”。收藏夹里躺着一百个视频,理论上都是学习材料,但真要找某个结论在哪一集、哪个时间点,翻起来会非常痛苦。视频转文字只是第一步,接下来要做的是多 P 批量转写、跨视频统一检索、AI 按片段回答问题,并且每个回答都要能溯源到原视频的具体位置,也就是角标溯源。这条链路本质上是 RAG 知识库在视频场景下的落地。本文会把从 B 站收藏到可检索知识库的完整过程拆开,讲清楚每一步为什么存在、用什么工具、怎么写代码、怎么验证,最后落到一套可以长期使用的个人知识库方案上。
1. 先把需求拆清楚:收藏夹里的视频为什么难以当作知识库来用
1.1 B 站收藏夹的真实使用困境
收藏夹的问题不是“存不进去”,而是“取不出来”。视频是时间轴上的线性信息流,用户看到某个知识点时,脑子里想的是“我记得某个 UP 主讲过这个概念”,但无法通过关键词搜到对应片段。浏览器搜索只能搜到标题和简介,搜不到视频里的具体语音内容;即使视频本身有字幕,也很少有人会把字幕导出、切片、做成索引。
把收藏夹变成知识库,首先要把“不可检索的音频流”转成“可检索的文本块”。这一步做好之后,后面的 AI 问答、角标溯源才有落点。
1.2 视频知识库的核心:把时间轴转成可检索文本
视频内容本质上是一个带时间轴的文本流。语音识别引擎输出的不是一句话,而是一条包含开始时间、结束时间、文本内容的段落序列。比如:
[ {"start": 12.5, "end": 28.3, "text": "向量是线性代数中最基本的概念"}, {"start": 28.3, "end": 45.1, "text": "它表示既有大小又有方向的量"} ]只要保留时间戳,这段文本就可以随时反查回视频。它同时具备两个价值:一是可以通过关键词或向量语义检索被找到;二是可以回到原视频的对应时间点观看上下文。
这就是“转写”和“观看笔记”的本质区别。普通笔记是用户整理后的结论,而带时间戳的转写文本是原视频的忠实结构化管理版本,后者更适合作为知识库底座。
1.3 多 P 批量、跨视频问答、角标溯源分别解决什么问题
- 多 P 批量解决的是规模化问题。B 站一个课程视频可能有几十个分 P,手动一个个转写完全不可行,必须做成批处理任务。
- 跨视频 AI 问答解决的是检索范围问题。用户的问题往往跨视频跨章节,不能只在一个视频里找答案,而是要在整个收藏集里召回相关内容再让模型回答。
- 角标溯源解决的是可信度问题。大模型回答如果没有来源,就失去了知识库的价值。角标的作用是让每个回答片段都能指回“哪个视频、第几 P、什么时间范围”。
这三件事连起来,才构成“收藏夹秒变知识库”的含义。
2. 视频转文字 + RAG 的技术链路,和纯文档知识库有什么不同
2.1 一条标准链路里的四个环节
一条可用的视频知识库链路至少包含四段:
- 语音识别转录:把视频音频转成带时间戳的文本段落。
- 文本切片:把转录段落按合理长度合并成适合向量化的块。
- 向量化索引:对文本块生成 embedding,连同视频元数据一起写入向量库。
- 检索问答:用户问题先向量检索召回相关片段,再交给大模型生成带有来源角标的回答。
用代码表达,这条链路的输入是一个 B 站视频页面 URL,输出是一个问答结果 JSON,中间全部由批量任务串联。
2.2 视频场景和文档 RAG 的关键差异
视频转文字的知识库,和普通的文档知识库在 RAG 层面最大的区别在于数据单元不同。
文档切分后通常只需要保留文字和页码;视频切分后必须额外保留三个字段:视频 ID、分 P 序号、开始时间戳。少了任何一个字段,角标溯源就无法实现。这也是很多人直接用现成 RAG 框架导入字幕文件后效果不好的原因——导入工具只识别了文本内容,丢掉了时间索引关系。
此外,语音识别文本和书面文档差异明显。转写文本没有标点规范,口语化严重,可能存在错别字、数字识别错误、人名错误。这意味着切片前的清洗比文档场景更重要。
2.3 一站式工具与自建流程如何取舍
像标题中提到的谛听 AI,以及市面上常见的知识库产品,目标都是把这条链路产品化。用户不需要写代码,上传视频或粘贴收藏夹链接,就能得到可检索的视频文本和问答对话。这类工具适合不想折腾环境、追求开箱即用的用户。
自建流程则适合需要深度控制的人。好处是可以自由选择转录引擎、调整切片策略、自定义溯源格式,并把数据完全掌握在自己手里。代价是环境搭建、依赖维护和后期调优都要自己处理。
从学习角度看,建议先自建一个最小闭环,理解链路是怎么跑的,然后再决定是否切换到一站式工具。下面几章就按自建方案逐步展开。
3. 环境准备与技术选型
3.1 转录引擎选型
转录是整个流程中算力消耗最大的一环。常见选择如下:
| 工具 | 特点 | 适合场景 | 注意事项 |
|---|---|---|---|
| faster-whisper | Whisper 的 CTranslate2 实现,CPU 也能跑 | 本地通用转写 | 中文建议至少用 small 或 medium 模型 |
| funasr / paraformer | 阿里开源中文语音识别模型,中文效果好 | 中文长视频批量转写 | 需要 Python 环境,模型下载体积较大 |
| 云端语音识别 API | 各家云厂商提供 | 对时效和精度要求高的生产环境 | 按时长计费,需评估成本 |
| B 站官方字幕 | 部分视频有 CC 字幕 | 有字幕的视频可直接使用 | 无时间戳或时间戳粒度不统一 |
如果原始材料没有明确指定转录引擎,本地学习环境优先推荐 faster-whisper。它安装简单,CPU 上也能跑通,输出 segments 天然包含 start 和 end 字段,非常贴合后续的溯源需求。
3.2 Embedding 与向量数据库选型
Embedding 模型负责把文本转成向量。中文环境下常用这几个:
| 模型 | 说明 | 使用建议 |
|---|---|---|
| BAAI/bge-small-zh-v1.5 | 轻量,本地 CPU 可运行 | 学习环境首选 |
| BAAI/bge-m3 | 多语言、多粒度,效果更好 | 资源充足时使用 |
| 云端 embedding API | 无需本地资源 | 按 token 计费,适合生产环境 |
向量库的选择取决于数据量:
| 工具 | 类型 | 建议 |
|---|---|---|
| Chroma | 轻量嵌入式向量库 | 个人学习、几百个视频以内很方便 |
| Qdrant | 独立向量数据库 | 数据规模较大,需要过滤和持久化时 |
| Milvus | 分布式向量库 | 生产集群方案,运维成本较高 |
| PGVector | PostgreSQL 插件 | 业务数据已经存在 Postgres 时 |
3.3 学习环境与生产环境的最小配置差异
学习环境可以跑在一台有 8GB 内存的 Mac 或 Linux 机器上,用 faster-whisper 的 small 模型加 Chroma 就能完成最小闭环。转录速度会比较慢,但足以验证流程。
生产环境至少要额外考虑三件事:
- 转录任务要队列化,避免一次性占满 GPU 或 CPU。
- 视频下载、转录、切片、向量化要分开记录状态,任一环节失败可以单独重试。
- 大模型调用要接入稳定的 API 网关,超时和限流要有兜底。
4. 最小闭环:把单个 P 视频转成带时间戳的向量库
4.1 准备待处理的音频文件
第一件事是从视频页面拿到音频。这里以 yt-dlp 作为示例工具,它支持 B 站地址解析。注意,下载的视频只用于个人学习场景,请勿二次分发或用于侵权用途。
yt-dlp -f "bestaudio" \ -o "video_%(id)s.%(ext)s" \ --extract-audio --audio-format mp3 \ "https://www.bilibili.com/video/BV1xx411c7mD"执行完后目录下会出现video_BV1xx411c7mD.mp3文件。如果不想用命令行下载工具,也可以直接上传本地录屏或已有视频文件,后续转录步骤不变。
4.2 转录并输出带时间戳的段落
使用 faster-whisper 转录,核心是拿到包含 start、end、text 的 segments 列表。
from faster_whisper import WhisperModel model = WhisperModel("small", device="cpu", compute_type="int8") segments, info = model.transcribe( "video_BV1xx411c7mD.mp3", vad_filter=True, language="zh", beam_size=5, ) records = [] for segment in segments: records.append({ "start": round(segment.start, 2), "end": round(segment.end, 2), "text": segment.text.strip(), }) print(records[:5])这里有几个关键参数需要理解:vad_filter=True会过滤静音段,减少无意义转写;beam_size=5控制解码搜索宽度,越大越准但越慢;language="zh"强制使用中文,避免开头几句话被误判为英文。
转写完成后,建议先把 records 保存为 JSON 文件,后续切片和向量化都从 JSON 读取。这样转录失败时不需要重新跑一次语音识别。
import json with open("records.json", "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False, indent=2)4.3 对转录文本切片和向量化
转录得到的 records 每段可能只有几十字,直接逐段向量化会让语义太碎。需要合并成几百字一个的文本块,同时保留块内第一个段落的 start 和最后一个段落的 end。
下面这个函数把相邻段落按字符数合并:
def build_chunks(records, max_chunk_chars=500): chunks = [] buf = [] buf_len = 0 def flush(): nonlocal buf, buf_len if not buf: return text = "".join(item["text"] for item in buf).strip() if text: chunks.append({ "text": text, "start": buf[0]["start"], "end": buf[-1]["end"], }) buf = [] buf_len = 0 for record in records: buf.append(record) buf_len += len(record["text"]) if buf_len >= max_chunk_chars: flush() flush() return chunks chunks = build_chunks(records) print(len(chunks), chunks[0])这段代码的关键是不要用固定长度硬切。语音转写的内容是连续语句,切在句中会破坏语义。按段落累积到阈值再成块,是相对稳妥的做法。如果想要更精细,可以在 flush 时找到最近的句号或问号再切。
生成 chunks 后,用 embedding 模型向量化并写入 Chroma:
from chromadb import Client from chromadb.config import Settings from sentence_transformers import SentenceTransformer embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5") client = Client(Settings( chroma_db_impl="duckdb+parquet", persist_directory="./chroma_data", )) collection = client.get_or_create_collection("bilibili_kb") ids = [] documents = [] metadatas = [] embeddings = [] for i, chunk in enumerate(chunks): ids.append(f"BV1xx411c7mD_p1_{i:04d}") documents.append(chunk["text"]) metadatas.append({ "bvid": "BV1xx411c7mD", "title": "线性代数课程", "page_index": 1, "page_title": "P1 向量", "start": chunk["start"], "end": chunk["end"], }) embeddings.append(embedder.encode(chunk["text"]).tolist()) collection.add( ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings, )Chroma 的 metadatas 只支持基础类型,不要嵌套字典;时间戳用 float,分 P 序号用 int,便于后面按元数据过滤。
4.4 保存元数据并验证查询
向量库已经写入,还需要把视频级元数据单独保存一份,包括视频标题、UP 主、分 P 列表、本地音频路径、转写 JSON 路径。建议以 video 为粒度建立目录:
data/ BV1xx411c7mD/ video.mp3 records.json chunks.json meta.json验证索引是否可用,直接查一个问题:
question = "什么是向量" result = collection.query( query_embeddings=[embedder.encode(question).tolist()], n_results=3, where={"page_index": 1}, ) for meta in result["metadatas"][0]: print(meta["page_title"], meta["start"], meta["end"], meta["title"])如果输出里有对应的分P和时间范围,说明转录、切片、向量化、索引这条链路已经跑通。
5. 多 P 批量处理:要建好元数据模型,而不是拼文件
5.1 多 P 视频为什么不能直接拼文件
很多课程视频是几十个分 P。如果直接把所有分 P 的音频拼接成一个文件再转写,会带来两个问题:一是时间戳从拼接文件开头算起,无法映射回每个分 P 的真实位置;二是单次转写时间极长,中途失败就要全部重来。
正确做法是按“视频 BV 号 + 分 P 序号”作为最小处理单元,每个分 P 独立转写,独立生成 records,最后统一写入同一个向量库,靠元数据区分来源。
5.2 元数据模型示例
一份适合多 P 场景的元数据模型如下:
{ "video": { "bvid": "BV1xx411c7mD", "title": "线性代数 45 讲", "uploader": "示例 UP 主", "video_url": "https://www.bilibili.com/video/BV1xx411c7mD" }, "pages": [ { "page_index": 1, "page_title": "P1 向量", "page_url": "https://www.bilibili.com/video/BV1xx411c7mD?p=1", "status": "done" }, { "page_index": 2, "page_title": "P2 矩阵", "page_url": "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "status": "done" } ] }每个分 P 转录完成后,把状态从 pending 改成 done。这样处理到一半系统重启,也能知道哪些分 P 已经完成,哪些需要重跑。
5.3 批量任务、去重和断点续跑
批量处理建议使用任务表,而不是 script 里的裸循环。任务表可以用 SQLite,也可以直接用 JSON 文件。核心字段是:
task_id bvid page_index status pending / running / done / failed retry_count error_message处理流程如下:
- 解析收藏夹,拿到所有视频的 BV 号和分P列表。
- 为每个分P创建 pending 任务。
- 消费者从任务表取 pending 任务,置为 running。
- 下载音频,转写,切片,向量化。
- 成功后置为 done;失败记录错误并置回 pending,retry_count 加 1。
- retry_count 超过阈值后,任务转人工处理。
写入向量库时要注意幂等性。Chroma 的 id 如果已经存在,再次 add 通常被视为更新,但前一次处理失败时可能留下脏数据。推荐在任务开始时用统一 id 规则删除已有记录,再重新写入。
collection.delete(where={"bvid": "BV1xx411c7mD", "page_index": 2})删除后用相同 id 规则重新写入,可以保证即使任务重复执行,也不会出现重复内容。
6. 跨视频 AI 问答与角标溯源的实现思路
6.1 检索召回:先找候选视频片段
跨视频问答的第一步不是直接问大模型,而是先在向量库中召回多个候选片段。这里有两个关键点:
- 不按视频维度过滤,除非用户明确指定只看某个视频。
- 召回数量要覆盖上下文,建议 n_results 取 5 到 8,太少容易漏信息,太多会超出模型的上下文窗口。
对于语义相近的问题,还可以加一次重排。如果没有专门的 rerank 模型,最简单的做法是同时用关键词检索和向量检索,合并结果后按相关度去重排序。这一步能明显减少模型答非所问的可能。
6.2 Prompt 构造:让模型只基于给定片段回答
召回的片段需要组装成结构化上下文,再传给大模型。Prompt 里的每个片段都要带上视频标题、分P编号、时间范围,这是后面角标生成的前提。
你是一个基于视频收藏集的知识库问答助手。 请只根据下面提供的视频片段回答用户问题。 如果片段中没有足够信息,请直接回答“收藏集中没有找到相关内容”。 片段列表: [片段0] 来源:线性代数课程 P1,时间 12.50 - 28.30 内容:向量是线性代数中最基本的概念,表示既有大小又有方向的量。 [片段1] 来源:线性代数课程 P2,时间 300.10 - 320.02 内容:矩阵可以看作线性变换的表示形式。 用户问题:什么是向量? 请给出答案,并在每个观点后面标注对应的片段编号。这段 Prompt 的核心理念是“引用约束”。模型被要求输出观点对应的片段编号,而不是自由编造来源。
6.3 角标生成与回填:从片段元数据到可点击来源
模型输出中会出现[片段0]这样的标记。后处理阶段需要把它替换成带可跳转链接的角标。
B 站网页端常用的跳转参数是?p=分P&t=秒数。可以按这个规则生成链接:
def build_source_link(meta): return ( f"{meta['title']} " f"P{meta['page_index']} " f"{format_time(meta['start'])}-{format_time(meta['end'])}" ) def format_time(seconds): seconds = int(seconds) m, s = divmod(seconds, 60) h, m = divmod(m, 60) if h > 0: return f"{h}:{m:02d}:{s:02d}" return f"{m}:{s:02d}"实际拼接链接时,需要用https://www.bilibili.com/video/{bvid}?p={page_index}&t={int(start)}这种 URL。不同客户端对 t 参数的支持度不一致,生成后可以先在网页端验证一次。
6.4 返回结构设计
AI 问答接口的返回结构建议包含 answer 和 sources 两部分。answer 是最终文本,sources 是结构化引用列表。这样前端渲染角标、读取原视频、定位片段都可以直接使用结构化数据,而不是从文本里正则解析。
{ "question": "什么是向量?", "answer": "根据收藏集中的课程内容,向量是线性代数中最基本的概念[1],表示既有大小又有方向的量。", "sources": [ { "index": 1, "bvid": "BV1xx411c7mD", "title": "线性代数课程", "page_index": 1, "page_title": "P1 向量", "start": 12.5, "end": 28.3, "url": "https://www.bilibili.com/video/BV1xx411c7mD?p=1&t=12" } ] }前端拿到 sources 后,可以把[1]渲染成可点击角标,点击跳转到对应视频时间点。这就是完整的角标溯源效果。
7. 验证方式:转录、检索、溯源都要能定量检查
7.1 转录质量检查
转录质量直接影响知识库效果。常见检查项:
- 打开 records.json,随机抽 10 个段落,和原视频画面对比。
- 确认时间戳是否连续,相邻 segment 的 start 和 end 是否出现倒挂。
- 检查中文术语和人名错误率。如果人名错得太多,需要准备自定义词典或换更强的模型。
如果使用 faster-whisper,可以开启initial_prompt传入视频里高频出现的专业词汇,例如课程名、人名、专有名词,能显著降低错字率。
7.2 检索质量检查
检索质量不能只看主观感受,建议准备一组测试问题,每道题记录“预期命中的视频片段”和“实际召回结果”。
常用的指标有三个:
| 指标 | 含义 | 使用场景 |
|---|---|---|
| Recall@K | 前 K 个结果是否包含目标片段 | 判断召回是否漏掉关键信息 |
| MRR | 目标片段在结果中的排名倒数均值 | 判断目标是否排得足够靠前 |
| 命中时间差 | 召回片段的 start 和目标知识点的实际时间差 | 判断时间戳切分是否合理 |
如果经常漏召回,优先检查 embedding 模型是否适合中文、切片是否把相关上下文拆散、是否需要加 rerank。
7.3 角标溯源正确性检查
溯源正确性检查的核心是:模型回答里的每句话,是否真的来自它标注的片段来源。
检查方法比较简单。把 answer 按照[角标]切分,随机抽查每个观点对应的 source 文本,确认观点确实能从该 source 中找到依据。如果模型明明引用片段 0,但观点在片段 0 中不存在,就是“无据引用”,需要调整 Prompt 或降低模型自由度。
一个有效的约束方式是在 Prompt 中显式写:如果观点来自多个片段,请并列标注。如果某个片段的文字不足以支持回答,请直接说没有相关内容,不要补全。
8. 常见问题与排查路径
8.1 一张排查表覆盖高频故障
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 转写结果全是英文 | 语言参数未设置或音频开头语音模糊 | 查看 transcripts 的 language 字段 | 强制指定 language="zh" |
| 时间戳混乱,出现倒挂 | 转录模型在长音频下输出不稳定 | 检查 adjacent segment 时间 | 分段转写或增加 vad_filter |
| 向量库查询结果不相关 | embedding 模型不适合中文或切片太碎 | 用固定问题测试不同切片大小 | 换 bge 系列模型,调大切片 |
| 多 P 处理到一半中断 | 没有任务状态记录 | 查看任务表 status | 实现断点续跑和失败重试 |
| AI 回答缺少来源 | Prompt 未约束角标输出 | 查看模型完整返回 | 在 Prompt 中要求必须标注片段编号 |
| 角标跳转时间不准 | t 参数单位计算错误 | 对比页面实际跳转时间 | 确认 start 单位是秒而不是毫秒 |
| Chroma 写入重复数据 | 任务重复执行未清理旧记录 | 查看 collection 中的 id | 写入前按 bvid+page_index 删除 |
8.2 一个典型排查案例
假设用户问“第 3 章讲了什么”,返回结果却全部来自第 1 章。按顺序排查:
- 确认问题本身没有包含第 1 章的措辞。
- 检查向量库中第 3 章的数据是否存在。可以用
collection.get(where={"page_index": 3})查看。 - 如果第 3 章的文本存在但没被召回,说明检索相关度不足。可以人工搜索“第3章”中的关键词,看是否能召回对应片段。
- 如果关键词搜索能召回而向量搜索不行,说明 embedding 对课程内部术语的区分度不够。此时最有效的办法不是换模型,而是给每个片段补充视频标题、UP 主、课程名称作为元数据,检索后按元数据过滤或重排。
这个案例说明,检索问题的排查链路永远是:数据在不在 -> 能不能搜到 -> 排序对不对 -> 模型回答是否忠实。跳步排查会浪费大量时间。
9. 最佳实践:从能跑到长期稳定
9.1 把转录文本当作一等公民来治理
转录文本不是一次性中间产物,而是知识库的原始资产。建议做到三条:
- 转录完成后不删除 records.json,后续切片策略调整时可以直接复用。
- 对转录文本做简单清洗,替换常见错字、去掉重复语气词。
- 保存原始音频路径,方便后续重新转录验证。
9.2 切分尺寸和重叠设计
切块大小没有绝对标准,建议按这个原则调:
- 视频口语一行的信息密度低于书面文档,500 到 800 字一个 chunk 是可接受区间。
- 如果问题是短问答型,块小一点,保证召回内容聚焦。
- 如果问题是综述型,块大一点,让模型有足够上下文。
- 增加 60 到 100 字的重叠,可以减少边界切断造成的语义断裂。
9.3 建立长期可用的检查清单
每次新增一批视频到知识库前,走一遍下面的清单:
| 检查项 | 确认方式 |
|---|---|
| 视频是否已有授权或属于个人学习范围 | 人工确认 |
| 音频文件是否完整 | 检查文件时长与页面时长 |
| 转录是否用了正确语言参数 | 查看 info.language |
| 时间戳是否连续 | 脚本检查倒挂 |
| 每个分 P 的元数据是否完整 | 检查 bvid、page_index、page_title |
| 向量库写入是否幂等 | 任务重复执行不产生重复 chunk |
| 问答是否返回角标 | 随机问一句验证 |
| 角标跳转链接是否有效 | 点击验证 |
9.4 生产环境额外保障
如果要把这套方案发布成服务或部署在团队环境,还需要补充:
- 转录任务接入消息队列,避免长任务阻塞 Web 服务。
- 配置中心化管理,大模型 API Key 不落在代码里。
- 监控转录失败率、检索耗时、大模型响应时间。
- 定期备份向量库和 records.json。
- 大模型接口超时后要有降级逻辑,比如返回“检索成功但模型服务不可用”。
9.5 扩展方向
这套架构可以自然延伸出几个方向:
- 接入弹幕和评论文本,作为视频内容的补充知识源。
- 增加演讲人识别,按说话人切分知识块。
- 对接完整 RAG 框架,比如 Dify、RAGFlow、MaxKB,把检索、对话、权限管理交给现成平台。
- 把视频转文字结果导出为 Markdown 或 Obsidian 笔记,形成个人笔记体系。
无论扩展到哪里,核心判断都不变:视频知识库的可用性取决于转录质量、元数据完整度和溯源能力,而不是模型参数越大越好。先把单视频转写和最小 RAG 闭环跑通,再考虑批量、评估和上层产品化,是一条更稳的落地路径。对新手来说,最有价值的练习不是追求一键脚本,而是亲手把转录、切片、向量化、答案回填这段链路写一遍,因为所有后续优化,最终都要回到这一段管线里找答案。