Fix Agent Citations for Custom Azure AI Search Schemas

Get real url/title citations from the Azure AI Search tool when your index fields aren't named url/title/content — register the index as an asset with a FieldMapping and use index_asset_id.

Author

Farzad Sunavala's avatar
Farzad Sunavala
Principal Product Manager
@farzad528

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

  1. Register your existing index as a project asset with a FieldMapping
  2. Create an agent that references the asset by index_asset_id
  3. Ask a question and read citations that resolve to your real url / title fields
  4. Learn the failure modes that produce doc_0 placeholders 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_id must be "<name>/versions/<version>", and it is mutually exclusive with project_connection_id + index_name. Set only index_asset_id, or the service rejects the request with Multiple 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.

Tags

azure-ai-search agents agent-service tools grounding retrieval