搜索应用和 RAG 的边界:微软生成式 AI 入门课第 8 课与第 15 课
翻 generative-ai-for-beginners 的人经常在这两课之间卡住:第 8 课 08-building-search-applications 讲用 embedding 做搜索,第 15 课 15-rag-and-vector-databases 讲 RAG 和向量库。两课都在讲切块、都在讲 embedding、都在算余弦相似度,看完容易觉得后一课是前一课的加长版。
真去读代码会发现不是。两条链路的终点不一样,检索到的东西交给谁也不一样。这两处差异决定了你该照哪一课抄。
先看第 8 课的链路终点
第 8 课的方案笔记本是 08-building-search-applications/python/aoai-solution.ipynb。它的主循环只有几步:
pd_vectors = load_dataset(DATASET_NAME)
while True:
query = input("Enter a query: ")
if query == "exit":
break
videos = get_videos(query, pd_vectors, 5)
display_results(videos, query)
load_dataset 读的是 DATASET_NAME = "../embedding_index_3m.json",也就是这一课目录下已经躺好的 embedding_index_3m.json。get_videos 里做的事是:用 client.embeddings.create(input=query, model=model) 把用户这句话向量化,然后对 DataFrame 的 ada_v2 列逐行调 cosine_similarity,写进新列 similarity,过滤、排序、取前几行。
关键在最后一步 display_results:
youtube_url = _gen_yt_url(row["videoId"], row["seconds"])
print(f" - {row['title']}")
print(f" Summary: {' '.join(row['summary'].split()[:15])}...")
print(f" YouTube: {youtube_url}")
print(f" Similarity: {row['similarity']}")
print(f" Speakers: {row['speaker']}")
检索结果被拼成一个带时间戳的 YouTube 链接,直接打印给用户。这条链路上没有第二次模型调用。 除了把 query 变成向量那一次,模型再没出现过。第 8 课的产物是一份排好序的候选列表,最终判断留给人。
第 15 课把结果喂给了模型
第 15 课 README 的「Bringing it all together」一节给出的 chatbot 函数,形状完全不同:
def chatbot(user_input):
query_vector = create_embeddings(user_input)
distances, indices = nbrs.kneighbors([query_vector])
history = []
for index in indices[0]:
history.append(flattened_df['chunks'].iloc[index])
history.append(user_input)
messages=[
{"role": "system", "content": "You are an AI assistant that helps with AI questions."},
{"role": "user", "content": "\n\n".join(history) }
]
response = client.responses.create(
model="gpt-5-mini",
max_output_tokens=800,
input=messages,
store=False,
)
return response.output_text
同样是取最近邻,但拿到 indices 之后走向变了:命中的 chunks 被 append 进 history,用户原话追加在最后,再用 "\n\n".join(history) 压成一条 user 消息发给模型,返回的是 response.output_text。
这就是两课的分水岭。第 8 课里检索结果是给人看的答案候选,第 15 课里检索结果是给模型看的上下文,用户看到的是模型二次加工过的文本。同样一次向量搜索,一个用来排序展示,一个用来拼 prompt。
顺带说一句这段代码的写法:检索到的片段和用户问题是拼在同一条 user 消息里的,中间只有空行分隔,没有「以下是资料」这类边界标记,system 消息里也没有交代资料从哪来。照抄这段做自己的应用时,这层边界要自己补上——课程正文里没有给这个约定。
这里还有一处必须先说清楚,否则你照着跑会一头雾水。 这一课的 notebook 15-rag-and-vector-databases/notebook-rag-vector-databases.ipynb 里也有一个同名的 chatbot,但它那条 user 消息写的是 {"role": "user", "content": history[-1]}。history[-1] 是刚刚 append 进去的用户原话——也就是说,前面用 kneighbors 取回来的 chunks 被装进了 history,却没有一条进入最终发出去的消息。README 版本用的是 "\n\n".join(history),这才是把上下文真的带上。两份代码摆在一起就是不一致的:以 notebook 为准照抄,检索那一段是白做的。另外 notebook 里模型名读的是 chat_deployment(来自 AZURE_OPENAI_DEPLOYMENT),README 里则直接写死了 gpt-5-mini——那只是仓库里的一个示例值,不是你必须用的模型。这两处以仓库最新内容为准,动手前先自己对一遍。
一行 drop 把差异写死了
如果只看一处代码就想确认这个分野,看第 8 课 load_dataset 的返回值:
def load_dataset(source: str) -> pd.core.frame.DataFrame:
pd_vectors = pd.read_json(source)
return pd_vectors.drop(columns=["text"], errors="ignore").fillna("")
索引文件里本来带着 text 列,加载时被 drop 掉了。往后整条链路只用到 title、summary、videoId、seconds、speaker 和向量列 ada_v2——片段原文根本不参与,因为没有任何一步需要把原文送进模型,展示时用的是预先生成好的 summary。
第 15 课正相反,它整条链路的目的就是把原文送进去,所以 chatbot 里取的是 flattened_df['chunks'].iloc[index],命中的块原样进 prompt。同一个「检索」动作,一边可以丢掉正文只留元信息,一边正文本身就是产物。
顺带一提相似度的算法来源也不同:第 8 课在笔记本里手写了 cosine_similarity,并且在两个向量长度不等时用 np.pad 补零对齐再算点积;第 15 课直接用 NearestNeighbors 的最近邻,距离由 sklearn 那边给。
索引是什么时候建的
第二处差异藏在索引的生成时机上。
第 8 课的索引是离线预制、随课程发货的。08-building-search-applications/scripts/ 下是一整套准备脚本,transcript_download.py 拉字幕,transcript_enrich_bucket.py 切段,transcript_enrich_speaker.py、transcript_enrich_summaries.py 补讲者和摘要,transcript_enrich_embeddings.py 调 embedding 接口。这一课的 README 明确写了学员不需要跑这些脚本,索引已经给你了。
顺带一个对不上的地方值得留意:README 正文说字幕被切成 3 分钟的段,而 transcript_enrich_bucket.py 里的 SEGMENT_LENGTH_MINUTES 写的是 5,并且可以用 -m/--minutes 覆盖。这是仓库当前代码里的默认值,随版本可能变动;真要复用这套脚本,以代码里的值和你传入的参数为准,别照 README 的说法估算段长。
Windows 上跑这套脚本的路子仓库里单列了。scripts/README.md 的环境变量一节,Windows 侧建议走「编辑系统环境变量 → 用户变量」加 AZURE_OPENAI_API_KEY、AZURE_OPENAI_ENDPOINT 等项,Linux 和 macOS 侧才是往 ~/.bashrc 或 ~/.zshrc 里写 export;虚拟环境激活也分成 .venv\Scripts\activate 和 source .venv/bin/activate 两种写法。脚本入口同样备了三份,prepare_transcripts_ai_show.ps1、.bat 和 .sh 都在 scripts/ 目录里。
第 15 课反过来,索引是课内现做的。它的数据在 15-rag-and-vector-databases/data/,就三个 markdown 文件:frameworks.md、own_framework.md、perceptron.md。README 里给了 split_text(text, max_length, min_length) 做切块,再用
from sklearn.neighbors import NearestNeighbors
nbrs = NearestNeighbors(n_neighbors=5, algorithm='ball_tree').fit(embeddings)
在本地建索引。可跑的骨架也只有一份:第 15 课的 notebook 是单个文件 notebook-rag-vector-databases.ipynb,直接躺在课目录下,没有按语言或 provider 分目录;第 8 课那边则是 python/、typescript/、dotnet/、js-githubmodels/ 四套实现并列,光 python/ 下面就分了 oai- 与 aoai- 两条路线各带 assignment 和 solution。想找一个贴近自己技术栈的起点,第 8 课的选择面更宽;第 15 课你基本只能走 notebook 里那条 Azure OpenAI / Microsoft Foundry 的 v1 endpoint 路子(base_url 拼的是 {endpoint}/openai/v1/)。
会咬到你的一处:没命中的时候返回什么
这是两课最该放在一起看的地方。
第 8 课的 aoai-solution.ipynb 里有一行常量 SIMILARITIES_RESULTS_THRESHOLD = 0.75,get_videos 用它做过滤:
mask = video_vectors["similarity"] >= SIMILARITIES_RESULTS_THRESHOLD
video_vectors = video_vectors[mask].copy()
0.75 是仓库当前代码里的取值,随版本可能变动。它的语义是:相似度够不着这条线的行会被整个滤掉,问一句索引里压根没有的东西,返回的可以是空。
第 15 课用的 NearestNeighbors(n_neighbors=5) 没有这层。kNN 按定义总会给你最近的那几条,不管它们到底有多远。第 15 课 README 自己也把这一点写出来了——它在检索一节承认,库里没有相近内容时系统仍会返回它能找到的最好结果,并建议设置相关性的最大距离,或者改用关键词加向量的混合检索。
同一处再补一个对不上的地方:README 说这一课要用 hybrid search,但 README 正文和这一课的 notebook 里给出的检索代码都只有向量这一路,关键词那一路我们在这一课里没有找到对应的代码。要混合检索,得自己补。
nbrs.kneighbors 返回的 distances 在 chatbot 里没有被用到,只有 indices 参与了拼 prompt。要在第 15 课的骨架上做出第 8 课那种「够不着就不返回」的行为,改动点就在这里——distances 是现成的,判断得自己加。
采样参数别照老写法套
第 15 课的 chatbot 用的是 Responses API,传的是 max_output_tokens=800,没有 temperature。这不是随手写的。06-text-generation-apps/README.md 里专门交代了这层:当前 Microsoft Foundry 上未废弃的模型是 reasoning 模型(GPT-5 家族、o 系列),不支持 temperature 和 top_p,也不支持 max_tokens,要用 max_output_tokens;给 gpt-5-mini 传 temperature 会拿到参数不支持的报错。你在第 15 课的骨架上加采样参数之前,先确认你指的是哪一类模型。
provider 路线的一个坑
第 8 课的 js-githubmodels/ 这个目录名今天已经名不副实了。00-course-setup/03-providers.md 和 js-githubmodels/app.js 的注释都写明,GitHub Models 已于 2026 年 7 月底退役,直接替代者是 Microsoft Foundry Models。对应地,app.js 里读的环境变量是 AZURE_INFERENCE_CREDENTIAL 和 AZURE_INFERENCE_ENDPOINT,注释里说这两个值去 Microsoft Foundry 项目的 Overview 页面拿——不是 GITHUB_TOKEN。看到 githubmodels- 前缀,按 Foundry Models 那条路线配。
另外一处对不上,第 8 课的 Python 笔记本里就有:开头的说明文字让你把 embedding 部署名写进 .env 的 AZURE_OPENAI_EMBEDDINGS_ENDPOINT,而下面的代码读的是 os.environ['AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT']。仓库根的 .env.copy 和 00-course-setup/03-providers.md 用的都是后者。以代码读的那个名字为准。
决策路径
不排优劣,只说该照哪一课的骨架起步:
- 用户要的是一批可点开的东西(视频、文档、商品),最终由人来挑——照第 8 课。它的
display_results已经把「结果附带定位信息」这件事做完了,_gen_yt_url把videoId和seconds拼成带时间戳的链接,这个模式换成文档的锚点也成立。 - 用户要的是一句直接回答,且答案必须来自你自己的资料——照第 15 课,它给了检索结果拼进 messages 再调模型的完整形状。代价是你要自己补边界标记、自己补距离判断。
- 语料是固定的、量大的、不常变的——第 8 课那套离线脚本更合手,索引建一次用很久。
- 语料是手头几个文档、随时会改的——第 15 课的
split_text加NearestNeighbors更轻,不用先立一套数据管线。 - 怕答非所问——两课都提供了处置线索,但形态不同:第 8 课是现成的阈值过滤,第 15 课是 README 里的建议加你自己用
distances实现。
有一类问题这两课都给不出依据,就不比了:哪一条链路检索得更准、更快、更省,仓库里没有任何可对照的说明,我们也没有跑过。这一点不比。
本文依据 github.com/microsoft/generative-ai-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与生成质量的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
本文对照的是同一项目内的两种用法,依据均为上述仓库内容,不对两种用法做优劣排名, 选型结论只在仓库文档写明的能力边界内成立。