Agentic RAG 检索为空:微软 AI Agent 入门课第 5 课三层排查
跑 ai-agents-for-beginners 第五课 05-agentic-rag/code_samples/05-python-agent-framework.ipynb 的时候,最容易撞上的不是报错,而是 Agent 客客气气回你一句「知识库里没有这方面的信息」。没有异常栈,没有红字,看上去一切正常。
这类现象最麻烦的地方在于:它可能是三个完全不同的问题,而三者的排查入口不在一处。下面按工具返回结构 → 查询字段对应 → 索引字段声明三层拆,每层给一个能直接在 notebook 里做的判定动作。绑定的事实文件就两份:上面那个 notebook,和 00-course-setup/AzureSearch.md。该课程持续更新,涉及的文件路径与接口写法以仓库最新内容为准。
第一层:先看清检索工具到底返回了什么
第五课的检索工具是 notebook 里一个用 @tool 装饰的普通函数 search_travel_knowledge,签名和返回类型都写死了:
@tool(approval_mode="never_require")
def search_travel_knowledge(
query: Annotated[str, "The search query about a travel destination"]
) -> str:
"""Search the travel knowledge base for destination information."""
关键在返回类型是 str,而且它的两条出口是这样写的:
return (
"\n\n".join(results)
if results
else "No matching destinations found in the knowledge base."
)
命中时每条结果被拼成 f"**{destination}**: {info}",多条之间用两个换行连接;一条都没命中时,返回的是一句英文自然语言。这里没有异常、没有空列表、没有状态码。 对框架来说,这次工具调用是成功的;模型收到的只是一段普普通通的文本。
再看 Agent 的 instructions,第三条恰好是 If information is not in the knowledge base, say so clearly。于是空检索会被原样翻译成一句得体的答复——现象和「知识库里真的没有」长得一模一样。
判定动作:不要从 Agent 那一端猜。把 notebook 里 TRAVEL_KNOWLEDGE_BASE 那个字典和函数体里的循环原样复制到一个新 cell,去掉装饰器,直接传几个你关心的字符串进去看返回值。装饰之后的对象还能不能像普通函数那样调用,仓库里没有找到相关说明,所以别在这上面赌,复制一份裸函数最稳。
顺带说一句 @tool(approval_mode="never_require") 这个取值:04-tool-use/README.md 与 02-explore-agentic-frameworks/README.md 的示例里都是同样写法;关于另一个取值,.agents/skills/deploying-scalable-agents/SKILL.md 里写的是 @tool(approval_mode="always_require") 对应需要人工批准的动作。除此之外,仓库里没有找到对这个参数更细的说明。排查检索为空时不要顺手改它。
第二层:模型构造的 query 与匹配表达式对不上
这一层是中文读者最容易踩的。函数体里的匹配条件是这一行:
if query.lower() in destination.lower() or any(
word in info.lower() for word in query.lower().split()
):
两个分支:要么整个 query 是某个目的地名字的子串,要么把 query 用 split() 切开之后,任意一个片段是 info 文本的子串。而知识库的键是英文目的地名(仓库示例里用的是 Barcelona 这类),值是对应的英文描述文本(这些都是仓库示例里的具体内容,不是通用配置)。
把这两处摆在一起,结论很直接:query.lower().split() 按空白切分,一句没有空格的中文查询切完还是它自己,再拿去和一段纯英文的 info 做子串匹配,不会命中。你用中文向 Agent 提问、模型顺手把中文原样塞进 query 参数,检索就是空的——而 Agent 那一层完全看不出来。
04-tool-use/README.md 里对 @tool 的说法是,框架会自动序列化函数及其参数、生成发给 LLM 的 schema。也就是说,模型看到的关于这个参数的全部提示,就是 Annotated[str, "The search query about a travel destination"] 里那句描述。它写的是「关于旅行目的地的搜索查询」,没有约定语言,也没有约定要传目的地名字还是整句问题。
判定动作:在函数体第一行加一句打印,把模型实际传进来的 query 打出来,再和上面那个匹配表达式对照。加打印是通用排查手段,不是仓库里的代码。看到实际 query 之后,判断就只剩一步:它按空白切开后的任一片段,是不是那几段英文里的子串。
第三层:换成真索引之后,字段声明对不上
notebook 的 Setup 一节写明,这一课用的是内存里的知识库,不需要 Azure AI Search 资源,同时给出了可选的 00-course-setup/AzureSearch.md 作为「用真实索引跑同一套模式」的指路;Summary 一节又写明,生产环境要把 TRAVEL_KNOWLEDGE_BASE 换成真实的 Azure AI Search 索引。换完之后,「检索为空」的成因就整体挪到了索引这一侧。
AzureSearch.md 的 Python 示例把索引字段定义成这样:
fields = [
SimpleField(name="id", type=edm.String, key=True),
SimpleField(name="content", type=edm.String, searchable=True),
]
紧接着上传的文档是:
documents = [
{"id": "1", "content": "Hello world"},
{"id": "2", "content": "Azure Cognitive Search"}
]
这里有两处对应关系必须成立,而它们在代码里是分开写的、编辑器不会替你校对:
其一,文档字典的键名要和字段名逐字一致。示例里是 id 和 content,你换成自己的语料时如果把正文塞进一个叫别的名字的键,索引侧不认识它。
其二,只有 content 这个字段带了 searchable=True,id 没有。你把正文放进哪个字段、那个字段有没有被声明为可检索,直接决定了全文检索能不能碰到它。同一份文档的 .NET 示例对应写法是 new SearchableField("content") 和 new SimpleField("id", SearchFieldDataType.String) { IsKey = true }——两侧写法不同,但划出来的是同一条界线。
还有一条更隐蔽的:索引可能压根就是空的。AzureSearch.md 的 Step 3 写明,该指南里的示例要做的是创建/更新索引并上传文档,因此需要 Search Service Contributor 与 Search Index Data Contributor 两个角色;走 key 的话必须用 primary admin key,文档特意注明不是 query key。用错凭据时上传那一步不成立,检索自然永远返回空。
顺带一提仓库里的一处不一致:Python 示例用的是 index_client.create_index(index),同一文件的 .NET 示例用的是 CreateOrUpdateIndexAsync。文档没有解释这个差异,所以改过字段定义再重跑时,先确认服务端索引里的实际字段是什么,别默认它已经被你的新定义覆盖了。
Windows 侧的环境变量别只走一条路
notebook 里读配置是 dotenv.load_dotenv() 加 os.getenv(...),需要 AZURE_AI_PROJECT_ENDPOINT 和 AZURE_AI_MODEL_DEPLOYMENT_NAME。而 AzureSearch.md 给检索服务的注入方式是 shell 会话变量,Linux/macOS 侧是:
export AZURE_SEARCH_SERVICE_ENDPOINT="https://<service-name>.search.windows.net"
Windows 侧是同一文件里的 PowerShell 段:
$env:AZURE_SEARCH_SERVICE_ENDPOINT = "https://<service-name>.search.windows.net"
$env:AZURE_SEARCH_API_KEY = $(az search admin-key show -g <resource-group> --service-name <service-name> --query "primaryKey" -o tsv)
这是两条不同的注入路径:一条来自 .env 文件,一条来自当前 shell 会话。在 PowerShell 里设完 $env:,再从别处启动 Jupyter 内核,那个内核不在这个会话里。另外 AzureSearch.md 在这几处都加了同一句注释:az search service show 没有 endpoint 字段,endpoint 要用服务名自己拼——照着抄,别去查一个不存在的字段。密钥请写成 <YOUR_API_KEY> 这类占位再提交代码。
以上代码块均原样取自仓库文件,未经实测,以仓库最新代码为准。
处置之后怎么验证
按层验证,别一次改三处:
- 工具层:裸函数版本传一个必然命中的字符串(比如仓库示例里的目的地名),确认返回的是
**目的地**: ...那种拼接结果,而不是那句No matching destinations found in the knowledge base.。 - 查询层:打印出来的 query 按空白切开后,确实有片段落在知识库文本里。
- 索引层:先确认索引里的字段名与可检索标记,再确认文档确实上传成功——上传这一步的权限要求在
AzureSearch.md里写得很清楚。
三层都过了,再把 Agent 接回来跑 notebook 里那两个提问(第二个用的是 checker_agent,它的 instructions 里要求对每个找到的目的地再按名字搜一次),看回答里的细节是不是来自知识库文本本身。
什么情况说明不是这个原因
- 环境变量缺失不会表现成检索为空。 notebook 里做 import 的那个 cell 显式写了
missing检查,缺AZURE_AI_PROJECT_ENDPOINT或AZURE_AI_MODEL_DEPLOYMENT_NAME会直接raise ValueError。你能跑到 Agent 回话,说明这一关已经过了。 - 返回一大堆不相关结果,是另一个方向的问题。 匹配条件里的
any(...)只要有一个片段命中就收录,一句长问题里随便一个常见英文短词都可能同时命中多条 info。这时候症状是结果过多而不是为空,改法也不同。 - Agent 根本没调工具,也不算这一类。 notebook 里注册工具写的是
client.as_agent(tools=[search_travel_knowledge], ...),而04-tool-use/README.md的示例写的是tools=get_current_time,不带列表。两种写法仓库里都有;如果 tools 压根没传进去,Agent 只会用自己的话回答,看起来同样像「没检索到」。先确认工具进没进去。 - 同一个 import cell 的开头有一行
logging.getLogger("agent_framework.foundry").setLevel(logging.ERROR),它把这个 logger 的级别设成了 ERROR。排查检索链路时可以先把这行去掉再看。
再强调一遍第一层那个点,因为它是这类问题反复出现的根源:这个检索工具用一句自然语言表达「没找到」。只要返回值仍然是 str,空结果和正常结果在类型上就没有区别,Agent 不可能替你分辨。真要在自己的项目里少踩几次,让工具在空结果时返回一个模型和你都能一眼认出的显式标记,比在 instructions 里多写两句约束更靠得住——这是通用做法,不是仓库里的内容。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。