微软生成式 AI 入门课第 15 课 RAG:向量库那段 notebook 逐块读
很多人第一次照着 RAG 教程敲完代码,模型的回答和不带检索时没什么两样,于是开始怀疑 embedding 模型选错了、切块大小不合适。但更常见的原因土得多:检索出来的片段压根没进提示词。
generative-ai-for-beginners 第 15 课的示例 notebook 恰好是个能把这件事看清楚的样本。这篇不复述课程正文,只沿着 15-rag-and-vector-databases/notebook-rag-vector-databases.ipynb 的 cell 顺序,把三处容易糊过去的地方挖开:切块的判断条件、数据最后落在哪些字段里、检索结果拼进提示词的确切位置。该课程持续更新,下面涉及的代码以仓库最新内容为准。
一、前置条件:这一课走的是 Azure OpenAI 那条路线
课程仓同一节常有多条 provider 路线,00-course-setup/03-providers.md 里用文件名标签区分:aoai 需要 Azure OpenAI 的 endpoint 与 key,oai 走 OpenAI,githubmodels 走 Microsoft Foundry Models(该文件写明后者取代 GitHub Models,GitHub Models 正在退役,具体时点以仓库最新说明为准)。第 15 课的 notebook 只有一份,代码里读的是 AZURE_OPENAI_ENDPOINT、AZURE_OPENAI_API_KEY、AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT、AZURE_OPENAI_DEPLOYMENT,客户端这样构造:
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
client = OpenAI(
api_key=os.getenv("AZURE_OPENAI_API_KEY"),
base_url=f"{endpoint.rstrip('/')}/openai/v1/",
)
也就是用 openai 包的 OpenAI 类,把 base_url 拼到 Azure 资源的 /openai/v1/ 上,不是 AzureOpenAI 类。这四个变量名在 00-course-setup/03-providers.md 的变量表里都有对应说明,跟着仓库根目录的 .env.copy 填即可。
要注意两件前置的事。
第一,这个 notebook 里没有 load_dotenv()。 它的 import 只有 os、pandas、numpy、openai,以及后面的 azure.cosmos 和 sklearn。仓库 requirements.txt 里确实列了 python-dotenv,但这份 notebook 没调用它,os.getenv 读的是进程环境变量。Windows 上如果你只是在项目目录里放了一个 .env 文件就直接启动 Jupyter,变量不会自己进来;PowerShell 里用 $env: 临时设置、或用 setx 写进用户环境后重开终端,都是可行的做法——这一段属于通用做法,不是课程仓的官方内容。Linux/macOS 侧同理,export 之后再启动内核。
第二,依赖清单在两个文件里对不上。 notebook 用到了 pandas 和 sklearn,requirements.txt 里列了 pandas、numpy、scikit-learn;而 pyproject.toml 的 dependencies 只列了 openai、python-dotenv、requests、azure-ai-inference、tiktoken,没有这两个。所以只按 pyproject.toml 装环境的人会在 cell 里撞到 ImportError。pyproject.toml 的 requires-python 写的是 >=3.10(这是仓库当前配置里的值,随版本可能变动)。
notebook 开头还有两个安装 cell,写法不一样:一个是 !pip install openai,另一个是 pip install azure-cosmos,后者没有前面那个感叹号。
二、切块:split_text 的条件比看上去窄
数据源是 15-rag-and-vector-databases/data/ 下的三个 markdown 文件,data_paths 里写的路径带着 ?WT.mc_id=... 查询串后缀,而目录里的实际文件名是 frameworks.md、own_framework.md、perceptron.md。这两处对不上,跑之前先核对一下你手里那份的路径字符串。
切块函数本体是:
def split_text(text, max_length, min_length):
words = text.split()
chunks = []
current_chunk = []
for word in words:
current_chunk.append(word)
if len(' '.join(current_chunk)) < max_length and len(' '.join(current_chunk)) > min_length:
chunks.append(' '.join(current_chunk))
current_chunk = []
# If the last chunk didn't reach the minimum length, add it anyway
if current_chunk:
chunks.append(' '.join(current_chunk))
return chunks
调用处是 splitted_df['chunks'] = splitted_df['text'].apply(lambda x: split_text(x, 400, 300)),400 和 300 是仓库示例里的取值。
有三点值得盯住。一是它按空格 split() 切词后逐个累加,长度判断用的是 len(),即拼接后字符串的字符数,不是 token 数——课程 README 在讲切块必要性时说的是 LLM 的输入 token 限制,代码这里落到的是字符长度,两者不是一回事。二是切块条件是一个区间:只有当前拼接长度同时小于 max_length 且大于 min_length 时才落一块。区间越窄,越有可能一个词跨过去就错过判断窗口。三是 README 在这一节明确提到,切块时可以补充上下文,比如加上文档标题或者前后文,而示例函数里没有做这件事。
切完之后是 splitted_df.explode('chunks') 展开成 flattened_df,一行一块,path 与 text 列跟着复制。
三、字段:Cosmos DB 连上了,但数据没写进去
这是最值得单独拎出来讲的一段。notebook 里确实有一段 Cosmos DB 代码:
from azure.cosmos import CosmosClient
# Initialize Cosmos Client
url = os.getenv('COSMOS_DB_ENDPOINT')
key = os.getenv('COSMOS_DB_KEY')
client = CosmosClient(url, credential=key)
# Select database
database_name = 'rag-cosmos-db'
database = client.get_database_client(database_name)
# Select container
container_name = 'data'
container = database.get_container_client(container_name)
数据库名 rag-cosmos-db 与容器名 data 都是仓库示例里的取值。前一个 cell 以注释形式给了 az login / az group create / az cosmosdb create / az cosmosdb list-keys 四条 Azure CLI 命令,以及一句「之后到 data explorer 里建库建容器」。
但通读整份 notebook,container 这个变量之后再没被用过:没有 create_item,没有 upsert_item,没有任何写入调用。真正承载数据的是那个 pandas DataFrame,最终 flattened_df 上出现过的列一共是:path(源文件路径)、text(整份文件原文)、chunks(切出来的块)、embeddings(每块的向量)、indices 与 distances(最近邻结果)。向量是这样生成并写回的:
embeddings = []
for chunk in flattened_df['chunks']:
embeddings.append(create_embeddings(chunk))
flattened_df['embeddings'] = embeddings
而 create_embeddings 走的是 client.embeddings.create(input=text, model=model).data[0].embedding,model 默认取 embeddings_deployment。
检索索引则完全在本地:
from sklearn.neighbors import NearestNeighbors
embeddings = flattened_df['embeddings'].to_list()
# Create the search index
nbrs = NearestNeighbors(n_neighbors=5, algorithm='ball_tree').fit(embeddings)
n_neighbors=5 与 algorithm='ball_tree' 是仓库示例里的参数值。把两处放在一起看结论很清楚:这一课的向量检索是 scikit-learn 的最近邻,Cosmos DB 那段是把连接建起来给你看形态,并不是这份示例的实际存储路径。另外 README 在检索一节写明「本课使用 hybrid search,向量与关键词结合」,而代码里我们只找到了向量最近邻,没有找到关键词检索部分。
还有一个变量名的坑:embeddings 这个名字在 notebook 里被复用了两次,先是那个装向量的 list,后来又被赋成 flattened_df['embeddings'].to_list()。乱序执行 cell 时容易搞混。
四、拼提示词的位置:README 和 notebook 写的不一样
最后的 chatbot 函数把检索和生成接上,notebook 里的版本是这样:
# add documents to query to provide context
history = []
for index in indices[0]:
history.append(flattened_df['chunks'].iloc[index])
# combine the history and the user input
history.append(user_input)
# create a message object
messages=[
{"role": "system", "content": "You are an AI assistant that helps with AI questions."},
{"role": "user", "content": history[-1]}
]
检索到的片段确实被逐条 append 进了 history,然后用户问题也被 append 到末尾。但 messages 里那条 user 消息取的是 history[-1],也就是刚刚追加的用户问题本身——前面那些检索片段没有进入这次请求。
而 15-rag-and-vector-databases/README.md 里同一个函数,这一行写的是 {"role": "user", "content": "\n\n".join(history) },即把检索片段和用户问题一起用空行拼成 user 消息。同一课的两个文件在同一处写法不同,两者取其一会得到完全不同的行为。这两处都在仓库里白纸黑字写着,孰为最终意图我们不做推断,但你照哪份敲,决定了上下文有没有真的送出去。
请求本身走 Responses API:
response = client.responses.create(
model=chat_deployment,
max_output_tokens=800,
input=messages,
store=False,
)
return response.output_text
max_output_tokens=800 是仓库示例里的取值;README 版本这里 model 直接写的是一个模型名字符串(仓库示例里写的是 "gpt-5-mini",只是示例取值),notebook 版本用的是从环境变量取的 chat_deployment,走 Azure 部署名。store=False 是代码里原样传入的参数,仓库没有对它另作说明。
五、边界
- 向量存储这一环在这份 notebook 里是没有闭合的:容器客户端建了,但代码里没有任何写入调用,向量只挂在
flattened_df这个 DataFrame 的列上。想做成能持久化的形态,得自己补写入与查询,仓库里没有给这部分代码。 - 课程 README 提到 Azure AI Search 的语义 reranker 会自动重排,但示例代码里标为 reranking 的那段,实际只是把最近邻结果打印出来;那段循环外层
for i in range(3)里又套了一个遍历全部indices[0]的内层循环,并带着一个for ... else,结构上外层的i没有起到取前三条的作用。 COSMOS_DB_ENDPOINT与COSMOS_DB_KEY这两个变量名,我们只在这份 notebook 里找到,.env.copy与00-course-setup/下的设置文档里没有找到对应说明,需要你自己补。- 评估那一段用的是
sklearn.metrics.average_precision_score与手写的test_cases列表,每条 case 里的relevant_responses与irrelevant_responses都是写死的样例文本;打分那一行是[1 if resp == response else 0 for resp in all_responses],即拿chatbot()的返回值与这些固定文本做字符串等值比较,而不是做语义层面的相关性判断。看这段代码时别把它当成一套可以直接复用的 RAG 评测方案。 - 关于向量维度、索引结构在大数据量下的表现,仓库里没有找到相关说明。
六、怎么验证接对了
按依赖顺序自查,别一路跑到最后才发现空的:
- 凭据这一关,notebook 里就有现成的探针 cell:
cat = create_embeddings("cat")。它能返回向量,说明 endpoint、key 与 embeddings 部署名三者对上了。 - 切块这一关,看
flattened_df.head(),确认chunks列不是整篇原文——如果每行 chunk 跟text一样长,说明split_text的区间条件一次都没命中。 - 索引这一关,
nbrs.kneighbors([query_vector])返回的indices[0]长度应当等于n_neighbors。 - 最关键的一关:在
client.responses.create之前,把messages打出来看一眼。
for m in messages:
print(m["role"], "->", m["content"][:200])
如果那条 user 消息里只有你的问题,没有任何检索片段,那就是第四节讲的那处差异;这时改回 README 的 "\n\n".join(history) 写法即可对齐。以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
跑通之后再回头看会发现,这一课真正教给人的不是「怎么调一个向量数据库」,而是 RAG 这条链路上每一段的产物长什么样:文本 → 块 → 向量 → 最近邻下标 → 提示词里的一段文字。链路上任何一段断了,最终表现都是同一个症状——答案看起来像没读过你的资料。
本文依据 github.com/microsoft/generative-ai-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与生成质量的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。