微软生成式 AI 入门课第 8 课:从 transcript 到 embedding
手里有一批视频,字幕能导出,想做的事很朴素:用户输入一句话,返回最相关的那几段视频,并且链接直接跳到那一分钟。真动手时卡住的往往不是「怎么调 embedding 接口」——那一行谁都会写——而是中间那段脏活:字幕怎么切段、切多长、切完丢什么留什么、向量存哪、检索时拿什么跟什么比。
generative-ai-for-beginners 的第 8 课 08-building-search-applications/ 把这段脏活完整摊开了。它不是一个「调一次 API 打印结果」的玩具:scripts/ 目录下摆着一整条数据流水线,章节 README 自述课程附带的那份 Embedding Index 就是用这套脚本生成的,同时也写明你不必自己跑这些脚本就能完成这一课。下面按仓库里的代码把这条链路走一遍。需要先说清楚:课程持续更新,文中涉及的文件路径、常量与接口写法以仓库最新内容为准。
前置条件:这一段别跳
这一课有两套依赖,别混。
课程正文那一套在 08-building-search-applications/python/requirements.txt,只需要 openai、pandas、plotly、matplotlib、scipy、scikit-learn、ipykernel。跑 notebook 够用了,因为索引文件 embedding_index_3m.json 已经躺在章节目录里。
数据准备那一套在 08-building-search-applications/scripts/requirements.txt,多出 tiktoken(本地数 token)、tenacity(重试)、rich(进度条)、google-api-python-client 与 youtube-transcript-api(拉字幕)。版本约束我这里不抄,仓库里写的是范围,会随更新变。
Python 版本上仓库自己就有两处说法:章节 README 写这套方案在 Windows 11、macOS 与 Ubuntu 22.04 上使用 Python 3.10 或更高版本构建与测试,而 scripts/README.md 的 Required software 一节写的是 Python 3.9 或以上。两处并存,取高的那个更保险。
环境变量方面,scripts/README.md 把 Windows 与类 Unix 分开写了。Windows 侧建议加到用户环境变量(Windows Start → Edit the system environment variables → Environment Variables),要配的是 AZURE_OPENAI_API_KEY、AZURE_OPENAI_ENDPOINT、AZURE_OPENAI_MODEL_DEPLOYMENT_NAME、GOOGLE_DEVELOPER_API_KEY;Linux 和 macOS 侧写成 export 放进 ~/.bashrc 或 ~/.zshrc。虚拟环境的激活方式也分开给了:Windows 是 .venv\Scripts\activate,macOS 与 Linux 是 source .venv/bin/activate。key 一律用你自己的 <YOUR_API_KEY>,别写进任何提交的文件。
另外,scripts/README.md 里的 Azure 侧准备步骤要求部署两个模型部署名,一个用于 embedding,一个用于 chat;脚本里读的部署名环境变量分别是 AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT 和 AZURE_OPENAI_MODEL_DEPLOYMENT_NAME,两者在代码里都用 os.getenv 带了默认值,这是仓库当前代码里的默认值,随版本可能变动。
五个 enrich 脚本,各改一处
驱动脚本有三份:scripts/prepare_transcripts_ai_show.ps1、.sh、.bat。三份的步骤顺序与传参完全一样,只是外壳语法不同(类 Unix 那份调的是 python3,另外两份是 python),Windows 用户直接用 .ps1 或 .bat,不用去凑 bash。核心就是顺序调用:
python transcript_download.py -f $TRANSCRIPT_FOLDER -p "PLlrxD0HtieHi0mwteKBOfEeOYf0LJU4O1"
python transcript_enrich_speaker.py -f $TRANSCRIPT_FOLDER
python transcript_enrich_bucket.py -f $TRANSCRIPT_FOLDER -m $TRANSCRIPT_BUCKET_MINUTES
python transcript_enrich_summaries.py -f $TRANSCRIPT_FOLDER
python transcript_enrich_embeddings.py -f $TRANSCRIPT_FOLDER
python transcript_enrich_lite.py -f $TRANSCRIPT_FOLDER
一个 -f 贯穿全程,所有脚本共用同一个 transcript 目录,靠固定文件名交接。逐个说它们改了什么:
transcript_enrich_speaker.py 干的是实体抽取,而且是用工具调用干的。它定义了一个名为 get_speaker_name 的函数描述,请求时通过 tool_choice={"type": "function", "name": "get_speaker_name"} 强制模型走这个函数,再从返回里挑出 type == "function_call" 的条目解析 arguments。喂给模型的文本是 get_first_segment() 取的开头一段字幕,加上标题和描述。结果写回每个视频的 .json 元数据文件的 speaker 字段——注意它是原地改写元数据,不产出新文件。
transcript_enrich_bucket.py 是整条链路里最值得读的一个。它把 .json.vtt 字幕按时间切段,切段条件是两个同时判断:
if current_seconds < seg_finish_seconds and total_tokens < MAX_TOKENS:
也就是「没到时间窗末尾」且「token 没超上限」才继续往当前段塞文本,任一条件破了就落段。这意味着一段的实际长度不由分钟数单独决定。段与段之间还留了重叠,由 append_text_to_previous_segment() 把新段开头的一部分词追加到上一段尾巴上:
append_text = " ".join(words[0 : int(word_count * PERCENTAGE_OVERLAP)])
看清楚它算的是谁的词数:传进去的 text 是正要落段的这一段,取的是这一段开头 PERCENTAGE_OVERLAP 比例的词,追加到上一段末尾。这里有一处值得放在一起看:章节 README 描述重叠时写的是「about 20 words」,而代码里是按比例算的(PERCENTAGE_OVERLAP 是模块常量),两者不是同一个口径,实际重叠词数随段落长度浮动。同样,SEGMENT_LENGTH_MINUTES 在脚本里有默认值,但驱动脚本用 -m 显式传了值进来,README 里描述的分钟数与最终文件名里的 3m 就是从这个参数来的。这一步产出 output/master_transcriptions.json。
transcript_enrich_summaries.py 给每段配摘要。system prompt 是仓库里的原文:"You're an AI Assistant for video, write an authoritative 60 word summary.Avoid starting sentences with 'This video'."——那个缺失的空格也是原样如此。摘要写进段的 summary 字段,产出 output/master_enriched.json。这里有个容易忽略的设计:chatgpt_summary() 抛 BadRequestError 或其它异常时,summary 会被兜底成原文本身,段不会丢,但你拿到的「摘要」其实是全文。
transcript_enrich_embeddings.py 才是算向量的那一步。它读回上一步的 master_enriched.json,把每段的 text 过一遍 normalize_text() 压掉空白与重复标点,再调 embedding 接口,把结果塞进 ada_v2 字段,最后写回同一个文件名。三个细节:一是它开头就判断 if "ada_v2" in segment 并直接跳过,所以重跑不会重复计费;二是它用 tiktoken.get_encoding("cl100k_base") 在本地先数 token,超过阈值的段直接 continue;三是重试用 tenacity 的 wait_random_exponential 加 stop_after_attempt,并且 retry_if_not_exception_type(BadRequestError)——请求本身不合法的错误不重试,这个取舍是对的。
transcript_enrich_lite.py 最短,也最关键:它用一个字典推导过滤掉每段里 text 和 description 两个键,产出 master_enriched_lite.json。驱动脚本的注释把这一步写成 removes the text property。至于为什么要去掉,仓库里没有写明理由;能确认的是检索那一侧从头到尾没有读过 text——notebook 的 load_dataset() 甚至会主动把它 drop 掉。
跑完之后驱动脚本做了一次改名:完整版改成 embedding_index_full_<分钟>m.json,瘦身版改成 embedding_index_<分钟>m.json。课程目录里给你的那个 embedding_index_3m.json,就是瘦身版。
索引长什么样
去掉正文之后,每段就剩七个键:speaker、title、videoId、start、seconds、summary、ada_v2。
把这七个键和检索那一侧的代码对着看,会发现它们没有一个是闲着的。notebook 里的 display_results() 用 videoId 和 seconds 拼跳转链接(_gen_yt_url() 拼的是 https://youtu.be/{video_id}?t={seconds} 这种形式),把 title、summary、speaker 打印出来,而 ada_v2 是算相似度时唯一被读的那一列。章节 README 写明 embedding 接口返回的是 1536 个数字构成的向量。
start 和 seconds 看着像重复:前者是 00:00:00 形式的字符串,后者是同一时刻的整数秒。但两份都有代码在用——进 URL 的是 seconds,而 transcript_enrich_embeddings.py 与 transcript_enrich_summaries.py 排序时调的 convert_time_to_seconds() 吃的是 start,它把字符串按冒号切三段再折算成秒。
检索这一步:一次点积,一个阈值
python/aoai-solution.ipynb 里的检索逻辑没有任何向量库,就是 pandas。load_dataset() 用 pd.read_json() 读进来,顺手 drop(columns=["text"], errors="ignore")——瘦身版本来就没有 text,这个 errors="ignore" 是为了兼容你自己跑出来的完整版。
相似度是手写的:
def cosine_similarity(a, b):
if len(a) > len(b):
b = np.pad(b, (0, len(a) - len(b)), 'constant')
elif len(b) > len(a):
a = np.pad(a, (0, len(b) - len(a)), 'constant')
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
同一课的 OpenAI 直连版本 python/oai-solution.ipynb 里,这个函数只有最后那一行返回语句,没有前面的补零分支。两份文件放在一起看,这个差异挺显眼:补零意味着两条长度不一致的向量也能算出一个数来,而不是抛错——如果你混用了不同 embedding 模型产出的索引,它不会告诉你,只会给你一个偏低的分数。
get_videos() 的三步很直白:先给查询算一次 embedding,然后对整列 ada_v2 逐行 apply 算相似度写成新列 similarity,再按阈值过滤、排序、取前 N 行:
mask = video_vectors["similarity"] >= SIMILARITIES_RESULTS_THRESHOLD
video_vectors = video_vectors[mask].copy()
video_vectors = video_vectors.sort_values(by="similarity", ascending=False).head(rows)
SIMILARITIES_RESULTS_THRESHOLD 是 notebook 顶部的常量,仓库当前代码里给的是 0.75,随版本可能变动。有阈值这一点比阈值取多少重要:没有它,任何查询都会返回排名前几的段,哪怕全都不相关。
边界:这套脚本没打算替你兜的事
- 超长段会被静默丢弃。
transcript_enrich_embeddings.py里 token 超限的分支是continue,既不写日志也不进output_segments。这条分支里也没有调用q.task_done()。分段逻辑理论上已经卡了 token 上限,但两个脚本用的编码不是同一个:分段用tiktoken.encoding_for_model("gpt-4o-mini"),算向量用get_encoding("cl100k_base"),口径不同就可能有漏网。 - 索引是静态快照。 章节 README 自述这份 Embedding Index 覆盖到 2023 年 10 月的字幕。它是教学素材,不是会自己更新的数据源。
- JSON 当索引是课程的简化。 README 明说为了课程简洁才用 JSON 文件加 DataFrame,生产环境应当换向量数据库,并列了几个可选方向。整表加载、逐行 apply 这种写法,本身就不是为规模设计的。
scripts/README.md与目录内容对不上。 那份 README 的安装步骤让你去 clone 另一个仓库、进data_prep目录、跑transcripts_prepare.ps1;而实际摆在scripts/里的驱动脚本叫prepare_transcripts_ai_show.ps1。以目录里实际存在的文件为准。- 速率相关的常量都是硬编码的。 各脚本顶部有线程数、超时秒数这类模块常量,不走命令行参数,要调只能改源码。
- 这条链路会把字幕内容发到云端。 摘要、抽取说话人、算向量三步都在调云服务,会产生费用,也意味着数据离开本机。素材涉密就别直接跑。
怎么确认自己配对了
不必先跑一遍流水线。最省事的自检是拿仓库自带的索引文件,确认你对结构的理解和代码一致:
import pandas as pd
pd_vectors = pd.read_json("../embedding_index_3m.json")
print(pd_vectors.columns.tolist())
print(len(pd_vectors.iloc[0]["ada_v2"]))
列名应当就是上面那七个键,向量长度应当与章节 README 写的维度对得上。若你自己跑了数据准备,还该确认 output/ 目录里按顺序出现过 master_transcriptions.json 与 master_enriched.json,且最终瘦身文件里已经没有 text 键——有的话说明 transcript_enrich_lite.py 那步没跑到。以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
至于环境变量配没配对,最直接的判据是 os.environ["AZURE_OPENAI_API_KEY"] 这种写法:脚本里用的是方括号取值而不是 os.getenv,缺变量会直接抛 KeyError 退出,不会带着空 key 去调接口——这算是这套脚本给你的一个免费检查点。
本文依据 github.com/microsoft/generative-ai-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与生成质量的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。