You have an Azure AI Search index whose URL, title, and content fields are not named
url / title / content — they're blob_url, uid, snippet, or whatever your blob or
SharePoint integrated-vectorization pipeline produced. You wire it into a Foundry Agent with the
Azure AI Search tool, the answers are great, but the url_citation annotations come back as
useless placeholders:
title='doc_0' url='https://<service>.search.windows.net/'
The pattern this recipe teaches: register the index as a project asset with a FieldMapping,
then point the AzureAISearchTool at it via index_asset_id. The agent's citations then resolve to
your real fields. No re-indexing, no schema change, no touching the index.
What you'll do
- Register your existing index as a project asset with a
FieldMapping - Create an agent that references the asset by
index_asset_id - Ask a question and read citations that resolve to your real
url/titlefields - Learn the failure modes that produce
doc_0placeholders and how to avoid each one
By the end you have a copyable two-step (create_or_update + index_asset_id) you can drop into any
agent that grounds on a custom-schema Azure AI Search index.
1 · Prerequisites
| Microsoft Foundry project | A project endpoint and one chat deployment (e.g. gpt-4.1) |
| Azure AI Search | An existing index, connected to the project as a CognitiveSearch connection |
| Identity | az login — the notebook uses DefaultAzureCredential |
You do not need to re-index or rename any fields. This recipe works against the schema you already have.
Install dependencies
%pip install --quiet "azure-ai-projects>=2.0.0" "azure-identity>=1.19.0"2 · Configure endpoints and your index's real field names
Set these in your shell (or a local .env) and the cell below reads them. The three field names at
the bottom are the whole point — they are the part of your index that differs from the docs.
PROJECT_ENDPOINT=https://<resource>.services.ai.azure.com/api/projects/<project>
SEARCH_CONNECTION_NAME=my-search-connection # the CognitiveSearch connection NAME, not its id
INDEX_NAME=my-custom-index # your existing index
import os
PROJECT_ENDPOINT = os.getenv("PROJECT_ENDPOINT", "https://<resource>.services.ai.azure.com/api/projects/<project>")
SEARCH_CONNECTION_NAME = os.getenv("SEARCH_CONNECTION_NAME", "my-search-connection") # connection NAME, not id
INDEX_NAME = os.getenv("INDEX_NAME", "my-custom-index")
MODEL = os.getenv("MODEL", "gpt-4.1")
# Your index's real field names -- the part that differs from the docs.
URL_FIELD = os.getenv("URL_FIELD", "blob_url") # your URL field -> annotation.url
TITLE_FIELD = os.getenv("TITLE_FIELD", "uid") # your title field -> annotation.title
CONTENT_FIELD = os.getenv("CONTENT_FIELD", "snippet") # your content field
print(f"project : {PROJECT_ENDPOINT}")
print(f"connection : {SEARCH_CONNECTION_NAME}")
print(f"index : {INDEX_NAME}")
print(f"fields : url={URL_FIELD!r} title={TITLE_FIELD!r} content={CONTENT_FIELD!r}")3 · Create the client
from azure.identity import DefaultAzureCredential
from azure.ai.projects import AIProjectClient
project = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=DefaultAzureCredential())
openai = project.get_openai_client()
print("client created")4 · Register the index as an asset with a field mapping
This is the step that makes citations work. FieldMapping maps your custom fields onto the citation
slots the tool understands. The mapping lives on the registered asset — not on the tool (see the
Gotchas table for why that distinction matters).
from azure.ai.projects.models import AzureAISearchIndex, FieldMapping
ASSET_NAME, ASSET_VERSION = "my-custom-index-mapped", "1"
asset = project.indexes.create_or_update(
name=ASSET_NAME, version=ASSET_VERSION,
index=AzureAISearchIndex(
name=ASSET_NAME, version=ASSET_VERSION,
connection_name=SEARCH_CONNECTION_NAME, # connection NAME
index_name=INDEX_NAME,
field_mapping=FieldMapping(
content_fields=[CONTENT_FIELD], # required
url_field=URL_FIELD, # -> annotation.url
title_field=TITLE_FIELD, # -> annotation.title
# filepath_field="...", # optional
),
),
)
print(f"registered asset {ASSET_NAME}/versions/{ASSET_VERSION}")5 · Create the agent, referencing the asset by index_asset_id
⚠️
index_asset_idmust be"<name>/versions/<version>", and it is mutually exclusive withproject_connection_id+index_name. Set onlyindex_asset_id, or the service rejects the request withMultiple values specified for oneof knowledge_index.
from azure.ai.projects.models import (
AzureAISearchTool, AzureAISearchToolResource, AISearchIndexResource,
AzureAISearchQueryType, PromptAgentDefinition,
)
agent = project.agents.create_version(
agent_name="search-custom-schema",
definition=PromptAgentDefinition(
model=MODEL,
instructions=(
"Answer only from the Azure AI Search tool. Always cite sources, "
"rendered as [message_idx:search_idx†source]."
),
tools=[AzureAISearchTool(azure_ai_search=AzureAISearchToolResource(indexes=[
AISearchIndexResource(
index_asset_id=f"{ASSET_NAME}/versions/{ASSET_VERSION}",
query_type=AzureAISearchQueryType.SEMANTIC, # or VECTOR_SEMANTIC_HYBRID
top_k=5,
)
]))],
),
)
print(f"agent {agent.name} v{agent.version}")6 · Ask a question and read the citations
Stream a response and pull the url_citation annotations off the final message. With the mapping in
place, title and url now carry your real field values.
stream = openai.responses.create(
stream=True, tool_choice="required",
input="What does the P4324 do?",
extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}},
)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="")
elif event.type == "response.output_item.done":
item = event.item
if item.type == "message" and item.content:
last = item.content[-1]
if getattr(last, "type", None) == "output_text":
for a in (last.annotations or []):
if a.type == "url_citation":
print(f"\nCITATION title={a.title!r} url={a.url!r}")Expected output — your real fields now surface instead of doc_0 placeholders:
CITATION title='P4324 Programmable Flow Controller — Overview'
url='https://contoso-docs.example.com/p4324/overview'
7 · Clean up (optional)
Delete the agent version. Keep the asset if you want to reuse the mapping for other agents.
project.agents.delete_version(agent_name=agent.name, agent_version=agent.version)
# project.indexes.delete(name=ASSET_NAME, version=ASSET_VERSION) # keep it to reuse the mapping
print("cleaned up agent")Gotchas
Every row here is a failure mode that produces broken or placeholder citations.
| Symptom | Cause | Fix |
|---|---|---|
title="doc_0", url=https://<svc>.search.windows.net/ |
Direct project_connection_id + index_name path — citations only read literal url / title fields |
Use the index_asset_id + FieldMapping path above |
Invalid IndexId format |
index_asset_id was a bare name or name:1 |
Must be "<name>/versions/<version>" (e.g. .../versions/1 or .../versions/latest) |
Multiple values specified for oneof knowledge_index |
Set both index_asset_id and project_connection_id / index_name |
Set only index_asset_id |
| Field mapping ignored | Passed parameters.field_mapping as a dict on the tool |
That key is silently dropped; the mapping must live on the registered asset, not the tool |
| Answer is right but citations wrong | The tool concatenates content regardless of field names, so answers work even when citations don't | The mapping fixes citations specifically |
Alternative (no asset registration): rename or alias your URL and title fields to literally url
and title in the index (indexer output field mappings, or write both on push). The direct path then
works too. Prefer the asset + FieldMapping route when you can't touch the index.
Verified run (real output)
Ran these steps verbatim on 2026-06-05 against a real index azstool-e2e-custom
(fields id, uid, blob_url, snippet) on a live Foundry project, starting from a fresh asset
registration. Actual console output:
[step 1] client created
[step 2] registered asset cookbook-verify-mapped/versions/1:
{'type': 'AzureSearch', 'connectionName': 'fsunavala-srch-demos-prod',
'indexName': 'azstool-e2e-custom',
'fieldMapping': {'contentFields': ['snippet'], 'titleField': 'uid', 'urlField': 'blob_url'},
'name': 'cookbook-verify-mapped', 'version': '1'}
[step 3] agent search-custom-schema v1
[step 4] streamed answer + citations:
The P4324 is a programmable industrial flow controller designed to regulate the flow rate of
liquids and gases in process pipelines. It does this by modulating a built-in proportional valve.
The device takes 4-20mA and Modbus RTU setpoints and can maintain flow to within +/- 0.5 percent
of the target value【4:0†source】.
CITATION title='P4324 Programmable Flow Controller — Overview' url='https://contoso-docs.example.com/p4324/overview'
[step 5] cleaned up agent + asset
Confirmed: title resolved from the index's uid field and url from its blob_url field —
no doc_0 placeholder, no https://<svc>.search.windows.net/ fallback. The same index referenced
directly (without the asset + FieldMapping) returns title='doc_0',
url='https://<svc>.search.windows.net/' — the broken baseline this recipe fixes.