Agentic RAG 检索为空:微软 AI Agent 入门课第 5 课三层排查

2026-08-18

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.md02-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"}
    ]

这里有两处对应关系必须成立,而它们在代码里是分开写的、编辑器不会替你校对:

其一,文档字典的键名要和字段名逐字一致。示例里是 idcontent,你换成自己的语料时如果把正文塞进一个叫别的名字的键,索引侧不认识它。

其二,只有 content 这个字段带了 searchable=Trueid 没有。你把正文放进哪个字段、那个字段有没有被声明为可检索,直接决定了全文检索能不能碰到它。同一份文档的 .NET 示例对应写法是 new SearchableField("content")new SimpleField("id", SearchFieldDataType.String) { IsKey = true }——两侧写法不同,但划出来的是同一条界线。

还有一条更隐蔽的:索引可能压根就是空的。AzureSearch.md 的 Step 3 写明,该指南里的示例要做的是创建/更新索引并上传文档,因此需要 Search Service ContributorSearch 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_ENDPOINTAZURE_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_ENDPOINTAZURE_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 实际表现的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。